dragonflight/services/web-ui/public/visuals.jsx
Zac Gaetano 1d642bd437 feat(web-ui): in-page delete confirm modal + WDL home footer
Replace 17 native window.confirm() destructive prompts with an in-page
ConfirmModal/useConfirm (added to visuals.jsx) across jobs/asset/editor/
ingest/projects/admin/playout/library. Add "Created by Wild Dragon LLC"
footer to the home launcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:31:07 -04:00

213 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// visuals.jsx - reusable visual elements
const _thumbCache = new Map();
function AssetThumb({ asset, size = 'md' }) {
const aspect = size === 'tall' ? '9 / 16' : '16 / 9';
const [thumbUrl, setThumbUrl] = React.useState(_thumbCache.get(asset.id) || null);
React.useEffect(() => {
if (!asset.id || thumbUrl || !asset.thumbnail_s3_key) return;
let cancelled = false;
fetch((window.ZAMPP_API_PREFIX || '/api/v1') + '/assets/' + asset.id + '/thumbnail', { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(d => { if (!cancelled && d && d.url) { _thumbCache.set(asset.id, d.url); setThumbUrl(d.url); } })
.catch(() => {});
return () => { cancelled = true; };
}, [asset.id, asset.thumbnail_s3_key]);
if (asset.type === 'audio' || asset.media_type === 'audio') {
return (
<div className="asset-thumb audio" style={{ aspectRatio: aspect }}>
<Waveform seed={asset.id ? asset.id.charCodeAt(0) % 60 : 1} />
<div className="thumb-overlay"><Icon name="audio" size={20} style={{ opacity: 0.9 }} /></div>
</div>
);
}
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
return (
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
{thumbUrl
? <img src={thumbUrl} alt={altText} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <FauxFrame />}
{asset.status === 'live' && (
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none' }} />
)}
</div>
);
}
function FauxFrame({ seed }) {
return <div className="thumb-svg" style={{ background: 'var(--bg-1)' }} />;
}
function Waveform({ seed = 1, color = 'var(--accent)', className = '' }) {
const bars = 60;
const pts = React.useMemo(() => {
return Array.from({ length: bars }).map((_, i) => {
const n = Math.sin(i * 0.7 + seed) * 0.5 + Math.sin(i * 2.1 + seed * 1.3) * 0.3 + Math.sin(i * 4.3 + seed * 0.7) * 0.2;
return Math.max(0.1, Math.min(1, 0.5 + n * 0.5));
});
}, [seed]);
return (
<svg viewBox="0 0 60 20" preserveAspectRatio="none" className={'waveform ' + className}>
{pts.map((p, i) => (
<rect key={i} x={i} y={10 - p * 9} width="0.6" height={p * 18} fill={color} rx="0.3" />
))}
</svg>
);
}
function LiveStrip({ seed = 1, count = 8 }) {
return (
<div className="live-strip">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="live-strip-cell" style={{ background: 'var(--bg-1)' }} />
))}
<div className="live-strip-now"><span className="live-pulse" />NOW</div>
</div>
);
}
function Sparkline({ data, color = 'var(--accent)', height = 28, fill = true }) {
const max = Math.max(...data, 1);
const min = Math.min(...data, 0);
const range = max - min || 1;
const w = 100;
const pts = data.map((d, i) => {
const x = (i / (data.length - 1)) * w;
const y = height - ((d - min) / range) * height;
return x + ',' + y;
}).join(' ');
const area = '0,' + height + ' ' + pts + ' ' + w + ',' + height;
return (
<svg viewBox={'0 0 ' + w + ' ' + height} preserveAspectRatio="none" style={{ width: '100%', height, display: 'block' }}>
{fill && <polygon points={area} fill={color} opacity="0.15" />}
<polyline points={pts} fill="none" stroke={color} strokeWidth="1.5" />
</svg>
);
}
function AudioMeter({ level = 0.7, peak = 0.85, vertical = false }) {
const segs = 20;
return (
<div className={'audio-meter ' + (vertical ? 'v' : 'h')}>
{Array.from({ length: segs }).map((_, i) => {
const v = i / segs;
const on = v < level;
const color = v < 0.6 ? 'var(--success)' : v < 0.85 ? 'var(--warning)' : 'var(--danger)';
return <div key={i} className="audio-seg" style={{ background: on ? color : 'var(--bg-3)', opacity: on ? 1 : 0.4 }} />;
})}
</div>
);
}
function StatusDot({ status }) {
const map = {
online: { color: 'var(--success)', pulse: false },
recording: { color: 'var(--live)', pulse: true },
armed: { color: 'var(--accent)', pulse: false },
idle: { color: 'var(--text-4)', pulse: false },
error: { color: 'var(--danger)', pulse: true },
offline: { color: 'var(--text-4)', pulse: false },
processing: { color: 'var(--warning)', pulse: true },
ready: { color: 'var(--success)', pulse: false },
live: { color: 'var(--live)', pulse: true },
queued: { color: 'var(--text-3)', pulse: false },
running: { color: 'var(--accent)', pulse: true },
done: { color: 'var(--success)', pulse: false },
failed: { color: 'var(--danger)', pulse: false },
stopped: { color: 'var(--text-4)', pulse: false },
};
const s = map[status] || { color: 'var(--text-3)' };
return <span className={'status-dot ' + (s.pulse ? 'pulse' : '')} style={{ background: s.color, boxShadow: '0 0 0 3px ' + s.color + '30' }} />;
}
function Elapsed({ seconds, live = false }) {
const [t, setT] = React.useState(seconds);
React.useEffect(() => {
if (!live) return;
const i = setInterval(() => setT(x => x + 1), 1000);
return () => clearInterval(i);
}, [live]);
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
return <span className="mono">{String(h).padStart(2,'0')}:{String(m).padStart(2,'0')}:{String(s).padStart(2,'0')}</span>;
}
// ─────────────────────────────────────────────────────────────────────────
// ConfirmModal + useConfirm — in-page replacement for window.confirm().
//
// Usage in a component:
// const [confirm, confirmModal] = useConfirm();
// ...
// if (!(await confirm({ title: 'Delete user?', message: '…' }))) return;
// ...
// return (<>{confirmModal} ...rest of UI... </>);
//
// confirm(opts) returns a Promise<boolean>. Options:
// title, message, confirmLabel (default 'Delete'), cancelLabel ('Cancel'),
// danger (default true → red confirm button).
function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = 'Cancel', danger = true, onConfirm, onCancel }) {
React.useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onCancel();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onConfirm, onCancel]);
return (
<div className="modal-backdrop" onClick={onCancel}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>{title}</div>
<button className="icon-btn" aria-label="Close" onClick={onCancel}><Icon name="x" /></button>
</div>
<div className="modal-body">
{typeof message === 'string'
? message.split('\n').map((line, i) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.5 }}>{line || ' '}</div>
))
: <div style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.5 }}>{message}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onCancel}>{cancelLabel}</button>
<button className={danger ? 'btn danger' : 'btn primary'} onClick={onConfirm} autoFocus>{confirmLabel}</button>
</div>
</div>
</div>
);
}
function useConfirm() {
const [state, setState] = React.useState(null); // { opts, resolve } | null
const confirm = React.useCallback((opts) => {
return new Promise((resolve) => {
setState({ opts: opts || {}, resolve });
});
}, []);
const close = React.useCallback((result) => {
setState((s) => {
if (s) s.resolve(result);
return null;
});
}, []);
const modal = state
? <ConfirmModal
{...state.opts}
onConfirm={() => close(true)}
onCancel={() => close(false)}
/>
: null;
return [confirm, modal];
}
Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed, ConfirmModal, useConfirm });