feat: custom file drop zone for asset upload modal; fix review issues
All checks were successful
Lint & Test / test (push) Successful in 1m29s
All checks were successful
Lint & Test / test (push) Successful in 1m29s
Replace plain <input type="file"> with a styled drag-and-drop zone featuring hover/drag states, file info display, and remove button. Fix 10 review issues: add 401 handling to upload, remove non-null assertions, add missing i18n keys (common.remove, error.play_failed, error.download_failed), normalize close button glyphs to ✕, i18n the dropzone aria-label, replace silent error catches with toast notifications, use DataTransfer for cross-browser file assignment.
This commit is contained in:
@@ -102,7 +102,10 @@ function initUploadDropzone(): void {
|
||||
dropzone.classList.remove('dragover');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
fileInput.files = files;
|
||||
// Use DataTransfer to safely assign files cross-browser
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(files[0]);
|
||||
fileInput.files = dt.files;
|
||||
showFile(files[0]);
|
||||
}
|
||||
});
|
||||
@@ -183,9 +186,11 @@ async function _playAssetSound(assetId: string) {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const audio = new Audio(blobUrl);
|
||||
audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl));
|
||||
audio.play().catch(() => {});
|
||||
audio.play().catch(() => showToast(t('asset.error.play_failed'), 'error'));
|
||||
_currentAudio = audio;
|
||||
} catch { /* ignore playback errors */ }
|
||||
} catch {
|
||||
showToast(t('asset.error.play_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal ──
|
||||
@@ -281,13 +286,14 @@ export async function uploadAsset(): Promise<void> {
|
||||
if (params.toString()) url += `?${params.toString()}`;
|
||||
|
||||
try {
|
||||
// Use raw fetch for multipart — fetchWithAuth forces Content-Type: application/json
|
||||
// which breaks the browser's automatic multipart boundary generation
|
||||
const headers = getHeaders();
|
||||
delete headers['Content-Type']; // Let browser set multipart boundary
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
delete headers['Content-Type'];
|
||||
const res = await fetch(url, { method: 'POST', headers, body: formData });
|
||||
if (res.status === 401) {
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail || 'Upload failed');
|
||||
@@ -365,8 +371,8 @@ export async function saveAssetMetadata(): Promise<void> {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description: description || null, tags }),
|
||||
});
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
|
||||
@@ -407,11 +413,11 @@ export async function deleteAsset(assetId: string): Promise<void> {
|
||||
export async function restorePrebuiltAssets(): Promise<void> {
|
||||
try {
|
||||
const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' });
|
||||
if (!res!.ok) {
|
||||
const err = await res!.json();
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.detail);
|
||||
}
|
||||
const data = await res!.json();
|
||||
const data = await res.json();
|
||||
if (data.restored_count > 0) {
|
||||
showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success');
|
||||
} else {
|
||||
@@ -439,7 +445,9 @@ async function _downloadAsset(assetId: string) {
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
showToast(t('asset.error.download_failed'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event delegation ──
|
||||
|
||||
Reference in New Issue
Block a user