= {
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: `
${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;