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'); dropzone.classList.remove('dragover');
const files = e.dataTransfer?.files; const files = e.dataTransfer?.files;
if (files && files.length > 0) { 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]); showFile(files[0]);
} }
}); });
@@ -183,9 +186,11 @@ async function _playAssetSound(assetId: string) {
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const audio = new Audio(blobUrl); const audio = new Audio(blobUrl);
audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl)); audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl));
audio.play().catch(() => {}); audio.play().catch(() => showToast(t('asset.error.play_failed'), 'error'));
_currentAudio = audio; _currentAudio = audio;
} catch { /* ignore playback errors */ } } catch {
showToast(t('asset.error.play_failed'), 'error');
}
} }
// ── Modal ── // ── Modal ──
@@ -281,13 +286,14 @@ export async function uploadAsset(): Promise<void> {
if (params.toString()) url += `?${params.toString()}`; if (params.toString()) url += `?${params.toString()}`;
try { try {
// Use raw fetch for multipart — fetchWithAuth forces Content-Type: application/json
// which breaks the browser's automatic multipart boundary generation
const headers = getHeaders(); const headers = getHeaders();
delete headers['Content-Type']; // Let browser set multipart boundary delete headers['Content-Type'];
const res = await fetch(url, { const res = await fetch(url, { method: 'POST', headers, body: formData });
method: 'POST', if (res.status === 401) {
headers, throw new Error('Session expired');
body: formData, }
});
if (!res.ok) { if (!res.ok) {
const err = await res.json(); const err = await res.json();
throw new Error(err.detail || 'Upload failed'); throw new Error(err.detail || 'Upload failed');
@@ -365,8 +371,8 @@ export async function saveAssetMetadata(): Promise<void> {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ name, description: description || null, tags }), body: JSON.stringify({ name, description: description || null, tags }),
}); });
if (!res!.ok) { if (!res.ok) {
const err = await res!.json(); const err = await res.json();
throw new Error(err.detail); throw new Error(err.detail);
} }
@@ -407,11 +413,11 @@ export async function deleteAsset(assetId: string): Promise<void> {
export async function restorePrebuiltAssets(): Promise<void> { export async function restorePrebuiltAssets(): Promise<void> {
try { try {
const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' }); const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' });
if (!res!.ok) { if (!res.ok) {
const err = await res!.json(); const err = await res.json();
throw new Error(err.detail); throw new Error(err.detail);
} }
const data = await res!.json(); const data = await res.json();
if (data.restored_count > 0) { if (data.restored_count > 0) {
showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success'); showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success');
} else { } else {
@@ -439,7 +445,9 @@ async function _downloadAsset(assetId: string) {
a.download = filename; a.download = filename;
a.click(); a.click();
URL.revokeObjectURL(blobUrl); URL.revokeObjectURL(blobUrl);
} catch { /* ignore */ } } catch {
showToast(t('asset.error.download_failed'), 'error');
}
} }
// ── Event delegation ── // ── Event delegation ──

View File

@@ -434,6 +434,7 @@
"confirm.turn_off_device": "Turn off this device?", "confirm.turn_off_device": "Turn off this device?",
"common.loading": "Loading...", "common.loading": "Loading...",
"common.delete": "Delete", "common.delete": "Delete",
"common.remove": "Remove",
"common.edit": "Edit", "common.edit": "Edit",
"common.clone": "Clone", "common.clone": "Clone",
"common.none": "None", "common.none": "None",
@@ -1995,6 +1996,8 @@
"asset.error.name_required": "Name is required", "asset.error.name_required": "Name is required",
"asset.error.no_file": "Please select a file to upload", "asset.error.no_file": "Please select a file to upload",
"asset.error.delete_failed": "Failed to delete asset", "asset.error.delete_failed": "Failed to delete asset",
"asset.error.play_failed": "Failed to play sound",
"asset.error.download_failed": "Failed to download asset",
"asset.play": "Play", "asset.play": "Play",
"asset.download": "Download", "asset.download": "Download",
"asset.prebuilt": "Prebuilt", "asset.prebuilt": "Prebuilt",

View File

@@ -434,6 +434,7 @@
"confirm.turn_off_device": "Выключить это устройство?", "confirm.turn_off_device": "Выключить это устройство?",
"common.loading": "Загрузка...", "common.loading": "Загрузка...",
"common.delete": "Удалить", "common.delete": "Удалить",
"common.remove": "Убрать",
"common.edit": "Редактировать", "common.edit": "Редактировать",
"common.clone": "Клонировать", "common.clone": "Клонировать",
"common.none": "Нет", "common.none": "Нет",
@@ -1924,6 +1925,8 @@
"asset.error.name_required": "Название обязательно", "asset.error.name_required": "Название обязательно",
"asset.error.no_file": "Выберите файл для загрузки", "asset.error.no_file": "Выберите файл для загрузки",
"asset.error.delete_failed": "Не удалось удалить ресурс", "asset.error.delete_failed": "Не удалось удалить ресурс",
"asset.error.play_failed": "Не удалось воспроизвести звук",
"asset.error.download_failed": "Не удалось скачать ресурс",
"asset.play": "Воспроизвести", "asset.play": "Воспроизвести",
"asset.download": "Скачать", "asset.download": "Скачать",
"asset.prebuilt": "Встроенный", "asset.prebuilt": "Встроенный",

View File

@@ -434,6 +434,7 @@
"confirm.turn_off_device": "关闭此设备?", "confirm.turn_off_device": "关闭此设备?",
"common.loading": "加载中...", "common.loading": "加载中...",
"common.delete": "删除", "common.delete": "删除",
"common.remove": "移除",
"common.edit": "编辑", "common.edit": "编辑",
"common.clone": "克隆", "common.clone": "克隆",
"common.none": "无", "common.none": "无",
@@ -1922,6 +1923,8 @@
"asset.error.name_required": "名称为必填项", "asset.error.name_required": "名称为必填项",
"asset.error.no_file": "请选择要上传的文件", "asset.error.no_file": "请选择要上传的文件",
"asset.error.delete_failed": "删除资源失败", "asset.error.delete_failed": "删除资源失败",
"asset.error.play_failed": "播放声音失败",
"asset.error.download_failed": "下载资源失败",
"asset.play": "播放", "asset.play": "播放",
"asset.download": "下载", "asset.download": "下载",
"asset.prebuilt": "内置", "asset.prebuilt": "内置",

View File

@@ -2,7 +2,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="asset-editor-title" data-i18n="asset.edit">Edit Asset</h2> <h2 id="asset-editor-title" data-i18n="asset.edit">Edit Asset</h2>
<button class="modal-close-btn" onclick="closeAssetEditorModal()" data-i18n-aria-label="aria.close">&times;</button> <button class="modal-close-btn" onclick="closeAssetEditorModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="hidden" id="asset-editor-id"> <input type="hidden" id="asset-editor-id">

View File

@@ -2,7 +2,7 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h2 id="asset-upload-title" data-i18n="asset.upload">Upload Asset</h2> <h2 id="asset-upload-title" data-i18n="asset.upload">Upload Asset</h2>
<button class="modal-close-btn" onclick="closeAssetUploadModal()" data-i18n-aria-label="aria.close">&times;</button> <button class="modal-close-btn" onclick="closeAssetUploadModal()" data-i18n-aria-label="aria.close">&#x2715;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="asset-upload-error" class="modal-error" style="display:none"></div> <div id="asset-upload-error" class="modal-error" style="display:none"></div>
@@ -12,8 +12,8 @@
<label for="asset-upload-name" data-i18n="asset.name">Name:</label> <label for="asset-upload-name" data-i18n="asset.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="asset.name.hint">Optional display name. If blank, derived from filename.</small> <small class="input-hint" style="display:none" data-i18n="asset.name.hint">Display name for this asset.</small>
<input type="text" id="asset-upload-name" maxlength="100" placeholder=""> <input type="text" id="asset-upload-name" maxlength="100">
<div id="asset-upload-tags-container"></div> <div id="asset-upload-tags-container"></div>
</div> </div>
@@ -34,7 +34,7 @@
<small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small> <small class="input-hint" style="display:none" data-i18n="asset.file.hint">Select a file to upload (sound, image, video, or other).</small>
<input type="file" id="asset-upload-file" required hidden> <input type="file" id="asset-upload-file" required hidden>
<div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button" <div id="asset-upload-dropzone" class="file-dropzone" tabindex="0" role="button"
aria-label="Choose file or drag and drop"> data-i18n-aria-label="asset.drop_or_browse">
<div class="file-dropzone-icon"> <div class="file-dropzone-icon">
<svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px"> <svg class="icon" viewBox="0 0 24 24" style="width:32px;height:32px">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/> <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
@@ -49,7 +49,7 @@
<span id="asset-upload-file-name" class="file-dropzone-filename"></span> <span id="asset-upload-file-name" class="file-dropzone-filename"></span>
<span id="asset-upload-file-size" class="file-dropzone-filesize"></span> <span id="asset-upload-file-size" class="file-dropzone-filesize"></span>
<button type="button" class="file-dropzone-remove" id="asset-upload-file-remove" <button type="button" class="file-dropzone-remove" id="asset-upload-file-remove"
title="Remove" data-i18n-title="common.remove">&times;</button> title="Remove" data-i18n-title="common.remove">&#x2715;</button>
</div> </div>
</div> </div>
</div> </div>