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.
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
/**
|
|
* Assets — file upload/download, CRUD, cards, modal.
|
|
*/
|
|
|
|
import { _cachedAssets, assetsCache } from '../core/state.ts';
|
|
import { fetchWithAuth, escapeHtml, API_BASE, getHeaders } from '../core/api.ts';
|
|
import { t } from '../core/i18n.ts';
|
|
import { Modal } from '../core/modal.ts';
|
|
import { showToast, showConfirm } from '../core/ui.ts';
|
|
import { ICON_CLONE, ICON_EDIT, ICON_DOWNLOAD, ICON_ASSET, ICON_TRASH, getAssetTypeIcon } from '../core/icons.ts';
|
|
import * as P from '../core/icon-paths.ts';
|
|
import { wrapCard } from '../core/card-colors.ts';
|
|
import { TagInput, renderTagChips } from '../core/tag-input.ts';
|
|
import { loadPictureSources } from './streams.ts';
|
|
import type { Asset } from '../types.ts';
|
|
|
|
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
|
|
const ICON_PLAY_SOUND = _icon(P.play);
|
|
const ICON_UPLOAD = _icon(P.fileUp);
|
|
const ICON_RESTORE = _icon(P.rotateCcw);
|
|
|
|
// ── Helpers ──
|
|
|
|
let _dropzoneInitialized = false;
|
|
|
|
/** Initialise the drop-zone wiring for the upload modal (once). */
|
|
function initUploadDropzone(): void {
|
|
if (_dropzoneInitialized) return;
|
|
_dropzoneInitialized = true;
|
|
const dropzone = document.getElementById('asset-upload-dropzone')!;
|
|
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
|
|
const infoEl = document.getElementById('asset-upload-file-info')!;
|
|
const nameEl = document.getElementById('asset-upload-file-name')!;
|
|
const sizeEl = document.getElementById('asset-upload-file-size')!;
|
|
const removeBtn = document.getElementById('asset-upload-file-remove')!;
|
|
|
|
const showFile = (file: File) => {
|
|
nameEl.textContent = file.name;
|
|
sizeEl.textContent = formatFileSize(file.size);
|
|
infoEl.style.display = '';
|
|
dropzone.classList.add('has-file');
|
|
// Hide the prompt text when a file is selected
|
|
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
|
if (labelEl) labelEl.style.display = 'none';
|
|
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
|
if (iconEl) iconEl.style.display = 'none';
|
|
};
|
|
|
|
const clearFile = () => {
|
|
fileInput.value = '';
|
|
infoEl.style.display = 'none';
|
|
dropzone.classList.remove('has-file');
|
|
const labelEl = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
|
if (labelEl) labelEl.style.display = '';
|
|
const iconEl = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
|
if (iconEl) iconEl.style.display = '';
|
|
};
|
|
|
|
// Click → open file picker
|
|
dropzone.addEventListener('click', (e) => {
|
|
if ((e.target as HTMLElement).closest('.file-dropzone-remove')) return;
|
|
fileInput.click();
|
|
});
|
|
|
|
// Keyboard: Enter/Space
|
|
dropzone.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
fileInput.click();
|
|
}
|
|
});
|
|
|
|
// File input change
|
|
fileInput.addEventListener('change', () => {
|
|
if (fileInput.files && fileInput.files.length > 0) {
|
|
showFile(fileInput.files[0]);
|
|
} else {
|
|
clearFile();
|
|
}
|
|
});
|
|
|
|
// Drag events
|
|
let dragCounter = 0;
|
|
dropzone.addEventListener('dragenter', (e) => {
|
|
e.preventDefault();
|
|
dragCounter++;
|
|
dropzone.classList.add('dragover');
|
|
});
|
|
dropzone.addEventListener('dragleave', () => {
|
|
dragCounter--;
|
|
if (dragCounter <= 0) {
|
|
dragCounter = 0;
|
|
dropzone.classList.remove('dragover');
|
|
}
|
|
});
|
|
dropzone.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
dropzone.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
dragCounter = 0;
|
|
dropzone.classList.remove('dragover');
|
|
const files = e.dataTransfer?.files;
|
|
if (files && files.length > 0) {
|
|
// Use DataTransfer to safely assign files cross-browser
|
|
const dt = new DataTransfer();
|
|
dt.items.add(files[0]);
|
|
fileInput.files = dt.files;
|
|
showFile(files[0]);
|
|
}
|
|
});
|
|
|
|
// Remove button
|
|
removeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
clearFile();
|
|
});
|
|
}
|
|
|
|
function formatFileSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function getAssetTypeLabel(assetType: string): string {
|
|
const map: Record<string, string> = {
|
|
sound: t('asset.type.sound'),
|
|
image: t('asset.type.image'),
|
|
video: t('asset.type.video'),
|
|
other: t('asset.type.other'),
|
|
};
|
|
return map[assetType] || assetType;
|
|
}
|
|
|
|
// ── Card builder ──
|
|
|
|
export function createAssetCard(asset: Asset): string {
|
|
const icon = getAssetTypeIcon(asset.asset_type);
|
|
const sizeStr = formatFileSize(asset.size_bytes);
|
|
const prebuiltBadge = asset.prebuilt
|
|
? `<span class="stream-card-prop" title="${escapeHtml(t('asset.prebuilt'))}">${_icon(P.star)} ${t('asset.prebuilt')}</span>`
|
|
: '';
|
|
|
|
let playBtn = '';
|
|
if (asset.asset_type === 'sound') {
|
|
playBtn = `<button class="btn btn-icon btn-secondary" data-action="play" title="${escapeHtml(t('asset.play'))}">${ICON_PLAY_SOUND}</button>`;
|
|
}
|
|
|
|
return wrapCard({
|
|
dataAttr: 'data-id',
|
|
id: asset.id,
|
|
removeOnclick: `deleteAsset('${asset.id}')`,
|
|
removeTitle: t('common.delete'),
|
|
content: `
|
|
<div class="card-header">
|
|
<div class="card-title" title="${escapeHtml(asset.name)}">
|
|
${icon} <span class="card-title-text">${escapeHtml(asset.name)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="stream-card-props">
|
|
<span class="stream-card-prop">${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))}</span>
|
|
<span class="stream-card-prop">${_icon(P.fileText)} ${sizeStr}</span>
|
|
${prebuiltBadge}
|
|
</div>
|
|
${renderTagChips(asset.tags)}`,
|
|
actions: `
|
|
${playBtn}
|
|
<button class="btn btn-icon btn-secondary" data-action="download" title="${escapeHtml(t('asset.download'))}">${ICON_DOWNLOAD}</button>
|
|
<button class="btn btn-icon btn-secondary" data-action="edit" title="${escapeHtml(t('common.edit'))}">${ICON_EDIT}</button>`,
|
|
});
|
|
}
|
|
|
|
// ── Sound playback ──
|
|
|
|
let _currentAudio: HTMLAudioElement | null = null;
|
|
|
|
async function _playAssetSound(assetId: string) {
|
|
if (_currentAudio) {
|
|
_currentAudio.pause();
|
|
_currentAudio = null;
|
|
}
|
|
try {
|
|
const res = await fetchWithAuth(`/assets/${assetId}/file`);
|
|
const blob = await res.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const audio = new Audio(blobUrl);
|
|
audio.addEventListener('ended', () => URL.revokeObjectURL(blobUrl));
|
|
audio.play().catch(() => showToast(t('asset.error.play_failed'), 'error'));
|
|
_currentAudio = audio;
|
|
} catch {
|
|
showToast(t('asset.error.play_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ── Modal ──
|
|
|
|
let _assetTagsInput: TagInput | null = null;
|
|
let _uploadTagsInput: TagInput | null = null;
|
|
|
|
class AssetEditorModal extends Modal {
|
|
constructor() { super('asset-editor-modal'); }
|
|
|
|
snapshotValues() {
|
|
return {
|
|
name: (document.getElementById('asset-editor-name') as HTMLInputElement).value,
|
|
description: (document.getElementById('asset-editor-description') as HTMLInputElement).value,
|
|
tags: JSON.stringify(_assetTagsInput ? _assetTagsInput.getValue() : []),
|
|
};
|
|
}
|
|
|
|
onForceClose() {
|
|
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
|
|
}
|
|
}
|
|
const assetEditorModal = new AssetEditorModal();
|
|
|
|
class AssetUploadModal extends Modal {
|
|
constructor() { super('asset-upload-modal'); }
|
|
snapshotValues() {
|
|
return {
|
|
name: (document.getElementById('asset-upload-name') as HTMLInputElement).value,
|
|
file: (document.getElementById('asset-upload-file') as HTMLInputElement).value,
|
|
tags: JSON.stringify(_uploadTagsInput ? _uploadTagsInput.getValue() : []),
|
|
};
|
|
}
|
|
onForceClose() {
|
|
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
|
|
}
|
|
}
|
|
const assetUploadModal = new AssetUploadModal();
|
|
|
|
// ── CRUD: Upload ──
|
|
|
|
export async function showAssetUploadModal(): Promise<void> {
|
|
const titleEl = document.getElementById('asset-upload-title')!;
|
|
titleEl.innerHTML = `${ICON_UPLOAD} ${t('asset.upload')}`;
|
|
|
|
(document.getElementById('asset-upload-name') as HTMLInputElement).value = '';
|
|
(document.getElementById('asset-upload-description') as HTMLInputElement).value = '';
|
|
(document.getElementById('asset-upload-file') as HTMLInputElement).value = '';
|
|
document.getElementById('asset-upload-error')!.style.display = 'none';
|
|
|
|
// Tags
|
|
if (_uploadTagsInput) { _uploadTagsInput.destroy(); _uploadTagsInput = null; }
|
|
const tagsContainer = document.getElementById('asset-upload-tags-container')!;
|
|
_uploadTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
|
|
|
|
// Reset dropzone visual state
|
|
const dropzone = document.getElementById('asset-upload-dropzone')!;
|
|
dropzone.classList.remove('has-file', 'dragover');
|
|
const dzLabel = dropzone.querySelector('.file-dropzone-label') as HTMLElement | null;
|
|
if (dzLabel) dzLabel.style.display = '';
|
|
const dzIcon = dropzone.querySelector('.file-dropzone-icon') as HTMLElement | null;
|
|
if (dzIcon) dzIcon.style.display = '';
|
|
document.getElementById('asset-upload-file-info')!.style.display = 'none';
|
|
|
|
initUploadDropzone();
|
|
|
|
assetUploadModal.open();
|
|
assetUploadModal.snapshot();
|
|
}
|
|
|
|
export async function uploadAsset(): Promise<void> {
|
|
const fileInput = document.getElementById('asset-upload-file') as HTMLInputElement;
|
|
const nameInput = document.getElementById('asset-upload-name') as HTMLInputElement;
|
|
const descInput = document.getElementById('asset-upload-description') as HTMLInputElement;
|
|
const errorEl = document.getElementById('asset-upload-error')!;
|
|
|
|
if (!fileInput.files || fileInput.files.length === 0) {
|
|
errorEl.textContent = t('asset.error.no_file');
|
|
errorEl.style.display = '';
|
|
return;
|
|
}
|
|
|
|
const file = fileInput.files[0];
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
let url = `${API_BASE}/assets`;
|
|
const params = new URLSearchParams();
|
|
const name = nameInput.value.trim();
|
|
if (name) params.set('name', name);
|
|
const desc = descInput.value.trim();
|
|
if (desc) params.set('description', desc);
|
|
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'];
|
|
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');
|
|
}
|
|
|
|
// Set tags via metadata update if any were specified
|
|
const tags = _uploadTagsInput ? _uploadTagsInput.getValue() : [];
|
|
const result = await res.json();
|
|
if (tags.length > 0 && result.id) {
|
|
await fetchWithAuth(`/assets/${result.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ tags }),
|
|
});
|
|
}
|
|
|
|
showToast(t('asset.uploaded'), 'success');
|
|
assetsCache.invalidate();
|
|
assetUploadModal.forceClose();
|
|
await loadPictureSources();
|
|
} catch (e: any) {
|
|
errorEl.textContent = e.message;
|
|
errorEl.style.display = '';
|
|
}
|
|
}
|
|
|
|
export function closeAssetUploadModal() {
|
|
assetUploadModal.close();
|
|
}
|
|
|
|
// ── CRUD: Edit metadata ──
|
|
|
|
export async function showAssetEditor(editId: string): Promise<void> {
|
|
const titleEl = document.getElementById('asset-editor-title')!;
|
|
const idInput = document.getElementById('asset-editor-id') as HTMLInputElement;
|
|
const nameInput = document.getElementById('asset-editor-name') as HTMLInputElement;
|
|
const descInput = document.getElementById('asset-editor-description') as HTMLInputElement;
|
|
const errorEl = document.getElementById('asset-editor-error')!;
|
|
|
|
errorEl.style.display = 'none';
|
|
const assets = await assetsCache.fetch();
|
|
const asset = assets.find(a => a.id === editId);
|
|
if (!asset) return;
|
|
|
|
titleEl.innerHTML = `${ICON_ASSET} ${t('asset.edit')}`;
|
|
idInput.value = asset.id;
|
|
nameInput.value = asset.name;
|
|
descInput.value = asset.description || '';
|
|
|
|
// Tags
|
|
if (_assetTagsInput) { _assetTagsInput.destroy(); _assetTagsInput = null; }
|
|
const tagsContainer = document.getElementById('asset-editor-tags-container')!;
|
|
_assetTagsInput = new TagInput(tagsContainer, { entityType: 'asset' });
|
|
_assetTagsInput.setValue(asset.tags || []);
|
|
|
|
assetEditorModal.open();
|
|
assetEditorModal.snapshot();
|
|
}
|
|
|
|
export async function saveAssetMetadata(): Promise<void> {
|
|
const id = (document.getElementById('asset-editor-id') as HTMLInputElement).value;
|
|
const name = (document.getElementById('asset-editor-name') as HTMLInputElement).value.trim();
|
|
const description = (document.getElementById('asset-editor-description') as HTMLInputElement).value.trim();
|
|
const errorEl = document.getElementById('asset-editor-error')!;
|
|
|
|
if (!name) {
|
|
errorEl.textContent = t('asset.error.name_required');
|
|
errorEl.style.display = '';
|
|
return;
|
|
}
|
|
|
|
const tags = _assetTagsInput ? _assetTagsInput.getValue() : [];
|
|
|
|
try {
|
|
const res = await fetchWithAuth(`/assets/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ name, description: description || null, tags }),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail);
|
|
}
|
|
|
|
showToast(t('asset.updated'), 'success');
|
|
assetsCache.invalidate();
|
|
assetEditorModal.forceClose();
|
|
await loadPictureSources();
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
errorEl.textContent = e.message;
|
|
errorEl.style.display = '';
|
|
}
|
|
}
|
|
|
|
export function closeAssetEditorModal() {
|
|
assetEditorModal.close();
|
|
}
|
|
|
|
// ── CRUD: Delete ──
|
|
|
|
export async function deleteAsset(assetId: string): Promise<void> {
|
|
const ok = await showConfirm(t('asset.confirm_delete'));
|
|
if (!ok) return;
|
|
|
|
try {
|
|
await fetchWithAuth(`/assets/${assetId}`, { method: 'DELETE' });
|
|
showToast(t('asset.deleted'), 'success');
|
|
assetsCache.invalidate();
|
|
await loadPictureSources();
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message || t('asset.error.delete_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ── Restore prebuilt ──
|
|
|
|
export async function restorePrebuiltAssets(): Promise<void> {
|
|
try {
|
|
const res = await fetchWithAuth('/assets/restore-prebuilt', { method: 'POST' });
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.detail);
|
|
}
|
|
const data = await res.json();
|
|
if (data.restored_count > 0) {
|
|
showToast(t('asset.prebuilt_restored').replace('{count}', String(data.restored_count)), 'success');
|
|
} else {
|
|
showToast(t('asset.prebuilt_none_to_restore'), 'info');
|
|
}
|
|
assetsCache.invalidate();
|
|
await loadPictureSources();
|
|
} catch (e: any) {
|
|
if (e.isAuth) return;
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ── Download ──
|
|
|
|
async function _downloadAsset(assetId: string) {
|
|
const asset = _cachedAssets.find(a => a.id === assetId);
|
|
const filename = asset ? asset.filename : 'download';
|
|
try {
|
|
const res = await fetchWithAuth(`/assets/${assetId}/file`);
|
|
const blob = await res.blob();
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(blobUrl);
|
|
} catch {
|
|
showToast(t('asset.error.download_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ── Event delegation ──
|
|
|
|
export function initAssetDelegation(container: HTMLElement): void {
|
|
container.addEventListener('click', (e: Event) => {
|
|
const btn = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
|
|
if (!btn) return;
|
|
const card = btn.closest('[data-id]') as HTMLElement | null;
|
|
if (!card || !card.closest('#stream-tab-assets')) return;
|
|
|
|
const action = btn.dataset.action;
|
|
const id = card.getAttribute('data-id');
|
|
if (!action || !id) return;
|
|
|
|
e.stopPropagation();
|
|
if (action === 'edit') showAssetEditor(id);
|
|
else if (action === 'delete') deleteAsset(id);
|
|
else if (action === 'download') _downloadAsset(id);
|
|
else if (action === 'play') _playAssetSound(id);
|
|
});
|
|
}
|
|
|
|
// ── Expose to global scope for HTML template onclick handlers ──
|
|
|
|
window.showAssetUploadModal = showAssetUploadModal;
|
|
window.closeAssetUploadModal = closeAssetUploadModal;
|
|
window.uploadAsset = uploadAsset;
|
|
window.showAssetEditor = showAssetEditor;
|
|
window.closeAssetEditorModal = closeAssetEditorModal;
|
|
window.saveAssetMetadata = saveAssetMetadata;
|
|
window.deleteAsset = deleteAsset;
|
|
window.restorePrebuiltAssets = restorePrebuiltAssets;
|