feat: asset-based image/video sources, notification sounds, UI improvements
Some checks failed
Lint & Test / test (push) Has been cancelled
Some checks failed
Lint & Test / test (push) Has been cancelled
- Replace URL-based image_source/url fields with image_asset_id/video_asset_id on StaticImagePictureSource and VideoCaptureSource (clean break, no migration) - Resolve asset IDs to file paths at runtime via AssetStore.get_file_path() - Add EntitySelect asset pickers for image/video in stream editor modal - Add notification sound configuration (global sound + per-app overrides) - Unify per-app color and sound overrides into single "Per-App Overrides" section - Persist notification history between server restarts - Add asset management system (upload, edit, delete, soft-delete) - Replace emoji buttons with SVG icons throughout UI - Various backend improvements: SQLite stores, auth, backup, MQTT, webhooks
This commit is contained in:
475
server/src/wled_controller/static/js/features/assets.ts
Normal file
475
server/src/wled_controller/static/js/features/assets.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* 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) {
|
||||
fileInput.files = 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(() => {});
|
||||
_currentAudio = audio;
|
||||
} catch { /* ignore playback errors */ }
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
const headers = getHeaders();
|
||||
delete headers['Content-Type']; // Let browser set multipart boundary
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
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 { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
Reference in New Issue
Block a user