feat: custom file drop zone for asset upload modal; fix review issues
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 &#x2715;,
i18n the dropzone aria-label, replace silent error catches with toast
notifications, use DataTransfer for cross-browser file assignment.
This commit is contained in:
2026-03-26 21:43:08 +03:00
parent f345687600
commit f61a0206d4
6 changed files with 38 additions and 21 deletions

View File

@@ -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 ──