/** * 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) => `${d}`; 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 = { 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 ? `${_icon(P.star)} ${t('asset.prebuilt')}` : ''; let playBtn = ''; if (asset.asset_type === 'sound') { playBtn = ``; } return wrapCard({ dataAttr: 'data-id', id: asset.id, removeOnclick: `deleteAsset('${asset.id}')`, removeTitle: t('common.delete'), content: `
${icon} ${escapeHtml(asset.name)}
${getAssetTypeIcon(asset.asset_type)} ${escapeHtml(getAssetTypeLabel(asset.asset_type))} ${_icon(P.fileText)} ${sizeStr} ${prebuiltBadge}
${renderTagChips(asset.tags)}`, actions: ` ${playBtn} `, }); } // ── 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 { 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 { 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 { 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 { 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 { 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 { 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;