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');
|
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 ──
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Встроенный",
|
||||||
|
|||||||
@@ -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": "内置",
|
||||||
|
|||||||
@@ -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">×</button>
|
<button class="modal-close-btn" onclick="closeAssetEditorModal()" data-i18n-aria-label="aria.close">✕</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">
|
||||||
|
|||||||
@@ -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">×</button>
|
<button class="modal-close-btn" onclick="closeAssetUploadModal()" data-i18n-aria-label="aria.close">✕</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">×</button>
|
title="Remove" data-i18n-title="common.remove">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user