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>
This commit is contained in:
Zac Gaetano 2026-05-31 18:31:07 -04:00
parent fffff1c016
commit 1d642bd437
11 changed files with 176 additions and 41 deletions

View file

@ -124,6 +124,7 @@ function Users() {
const [editingUser, setEditingUser] = React.useState(null);
const [resetUser, setResetUser] = React.useState(null);
const [menuFor, setMenuFor] = React.useState(null); // row id whose menu is open
const [confirm, confirmModal] = window.useConfirm();
const refreshUsers = React.useCallback(() => {
window.ZAMPP_API.fetch('/users')
@ -169,9 +170,9 @@ function Users() {
const onCreated = () => { refreshUsers(); setShowInvite(false); };
const deleteUser = (u) => {
const deleteUser = async (u) => {
setMenuFor(null);
if (!confirm(`Delete user "${u.name}" (@${u.username})?\nThis cannot be undone.`)) return;
if (!(await confirm({ title: 'Delete user?', message: `Delete user "${u.name}" (@${u.username})?\nThis cannot be undone.` }))) return;
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'DELETE' })
.then(refreshUsers)
.catch(e => alert('Delete failed: ' + e.message));
@ -188,6 +189,7 @@ function Users() {
return (
<div className="page">
{confirmModal}
<div className="page-header">
<h1>Users &amp; Groups</h1>
<div className="spacer" />
@ -296,6 +298,7 @@ function Users() {
function PoliciesPanel({ users, onChange }) {
const [expandedId, setExpandedId] = React.useState(null);
const [err, setErr] = React.useState(null);
const [confirm, confirmModal] = window.useConfirm();
const changeRole = (u, newRole) => {
if (u.role === newRole) return;
@ -307,8 +310,8 @@ function PoliciesPanel({ users, onChange }) {
// Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
// body). Mirrors the disable() pattern in TotpSection.
const resetTotp = (u) => {
if (!confirm(`Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`)) return;
const resetTotp = async (u) => {
if (!(await confirm({ title: 'Reset two-factor?', message: `Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`, confirmLabel: 'Reset 2FA' }))) return;
setErr(null);
fetch('/api/v1/users/' + u.id + '/totp/disable', {
method: 'POST',
@ -324,6 +327,7 @@ function PoliciesPanel({ users, onChange }) {
return (
<div>
{confirmModal}
{/* Access-model explainer (kept from the old static tab, condensed) */}
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
@ -609,6 +613,7 @@ function GroupsPanel({ groups, users, onChange }) {
const [newDesc, setNewDesc] = React.useState('');
const [expandedId, setExpandedId] = React.useState(null);
const [members, setMembers] = React.useState({}); // groupId -> [user]
const [confirm, confirmModal] = window.useConfirm();
const createGroup = () => {
if (!newName.trim()) return;
@ -617,8 +622,8 @@ function GroupsPanel({ groups, users, onChange }) {
.catch(e => alert('Create failed: ' + e.message));
};
const deleteGroup = (g) => {
if (!confirm(`Delete group "${g.name}"?`)) return;
const deleteGroup = async (g) => {
if (!(await confirm({ title: 'Delete group?', message: `Delete group "${g.name}"?` }))) return;
window.ZAMPP_API.fetch('/groups/' + g.id, { method: 'DELETE' })
.then(onChange)
.catch(e => alert('Delete failed: ' + e.message));
@ -653,6 +658,7 @@ function GroupsPanel({ groups, users, onChange }) {
return (
<div>
{confirmModal}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
<div style={{ flex: 1, fontSize: 12, color: 'var(--text-3)' }}>
Groups let you bundle users for project access. Memberships are checked when role-based access is enforced.
@ -1015,6 +1021,7 @@ function Containers() {
const [containers, setContainers] = React.useState(null);
const [restartFlashState, setRestartFlashState] = React.useState(null);
const [logsModalState, setLogsModalState] = React.useState(null);
const [confirm, confirmModal] = window.useConfirm();
// #111 - guard restart-flash timers against unmount.
const mountedRef = React.useRef(true);
const flashTimerRef = React.useRef(null);
@ -1045,8 +1052,8 @@ function Containers() {
const showLogs = (c) => setLogsModal(c);
const restartContainer = (c) => {
if (!window.confirm('Restart container "' + c.name + '"?\nIn-flight requests will be dropped.')) return;
const restartContainer = async (c) => {
if (!(await confirm({ title: 'Restart container?', message: 'Restart container "' + c.name + '"?\nIn-flight requests will be dropped.', confirmLabel: 'Restart' }))) return;
setRestartFlashSafe({ name: c.name, status: 'pending' });
window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' })
.then(() => {
@ -1063,6 +1070,7 @@ function Containers() {
return (
<div className="page">
{confirmModal}
<div className="page-header">
<h1>Containers</h1>
<span className="subtitle">Docker Compose services across the cluster</span>
@ -1438,6 +1446,7 @@ function Cluster() {
const [hovered, setHovered] = React.useState(null);
// Map of "node_id:portIndex" signal entry from /cluster/devices/blackmagic/signal
const [portSignals, setPortSignals] = React.useState({});
const [confirm, confirmModal] = window.useConfirm();
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/cluster')
@ -1522,8 +1531,8 @@ function Cluster() {
commands: [`ssh ${node.ip || node.id} 'docker stop node-agent'`],
});
const removeNode = (node) => {
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine: it only removes it from cluster membership.')) return;
const removeNode = async (node) => {
if (!(await confirm({ title: 'Remove node?', message: 'Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine: it only removes it from cluster membership.', confirmLabel: 'Remove' }))) return;
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' })
.then(() => refresh())
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
@ -1537,6 +1546,7 @@ function Cluster() {
return (
<div className="page">
{confirmModal}
<div className="page-header">
<h1>Cluster</h1>
<span className="subtitle">{NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online</span>
@ -2733,6 +2743,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
const fileRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const [confirm, confirmModal] = window.useConfirm();
const deployed = status && status.file_count > 0;
const lastUpload = status?.uploaded_at
@ -2774,8 +2785,8 @@ function SdkVendorRow({ vendor, status, onDone }) {
});
};
const clear = () => {
if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;
const clear = async () => {
if (!(await confirm({ title: 'Remove staged SDK files?', message: 'Remove staged ' + vendor.name + ' SDK files?', confirmLabel: 'Remove' }))) return;
window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })
.then(() => onDone(vendor.name + ': cleared.', true))
.catch(e => onDone(vendor.name + ': ' + e.message, false));
@ -2783,6 +2794,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: 12, background: 'var(--bg-2)' }}>
{confirmModal}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<strong style={{ fontSize: 13 }}>{vendor.name}</strong>
{deployed

View file

@ -23,6 +23,7 @@ function AssetDetail({ asset, onClose }) {
const [comments, setComments] = React.useState([]);
const [newComment, setNewComment] = React.useState("");
const [commentsLoading, setCommentsLoading] = React.useState(false);
const [confirm, confirmModal] = window.useConfirm();
// Stream / video state
const [streamUrl, setStreamUrl] = React.useState(null);
@ -231,9 +232,12 @@ function AssetDetail({ asset, onClose }) {
if (navigator.clipboard) navigator.clipboard.writeText(assetId).catch(function() {});
setMenuOpen(false);
};
const deleteAsset = function() {
const deleteAsset = async function() {
setMenuOpen(false);
if (!window.confirm('Delete "' + (asset.name || assetId) + '" permanently?\nRemoves DB row + S3 objects. Cannot be undone.')) return;
if (!(await confirm({
title: 'Delete asset?',
message: 'Delete "' + (asset.name || assetId) + '" permanently?\nRemoves DB row + S3 objects. Cannot be undone.',
}))) return;
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
.then(function() { onClose && onClose(); })
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
@ -325,8 +329,8 @@ function AssetDetail({ asset, onClose }) {
.catch(function() {});
};
const deleteComment = function(c) {
if (!confirm('Delete this comment?')) return;
const deleteComment = async function(c) {
if (!(await confirm({ title: 'Delete comment?', message: 'Delete this comment?' }))) return;
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'DELETE' })
.then(function() {
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
@ -355,6 +359,7 @@ function AssetDetail({ asset, onClose }) {
return (
<div className="asset-detail fade-in">
{confirmModal}
<div className="asset-detail-header">
<button className="icon-btn" aria-label="Back" onClick={onClose}><Icon name="arrowLeft" /></button>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>

View file

@ -7,6 +7,7 @@ function Editor() {
const [projectId, setProjectId] = React.useState(null);
const [sequences, setSequences] = React.useState([]);
const [currentSeq, setCurrentSeq] = React.useState(null);
const [confirm, confirmModal] = window.useConfirm();
const [assets, setAssets] = React.useState([]);
const [bins, setBins] = React.useState([]);
const [sourceAsset, setSourceAsset] = React.useState(null);
@ -159,7 +160,7 @@ function Editor() {
async function deleteSequence() {
if (!currentSeq) return;
if (!window.confirm('Delete sequence "' + currentSeq.name + '"? This cannot be undone.')) return;
if (!(await confirm({ title: 'Delete sequence?', message: 'Delete sequence "' + currentSeq.name + '"? This cannot be undone.' }))) return;
try {
await window.ZAMPP_API.deleteSequence(currentSeq.id);
const remaining = sequences.filter(s => s.id !== currentSeq.id);
@ -377,6 +378,7 @@ function Editor() {
return (
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{confirmModal}
{/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
<div className="editor-beta-banner">

View file

@ -284,6 +284,8 @@ function Home({ navigate }) {
</span>
)}
</div>
<div className="launcher-footer">Created by Wild Dragon LLC</div>
</div>
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
</div>

View file

@ -587,6 +587,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
const [clipName, setClipName] = React.useState('');
// Project override for this take. Defaults to the recorder's configured project.
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
const [confirm, confirmModal] = window.useConfirm();
const isRec = recorder.status === 'recording';
// Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh).
@ -657,8 +658,8 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
};
const handleDelete = () => {
if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
const handleDelete = async () => {
if (!(await confirm({ title: 'Delete recorder?', message: 'Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.' }))) return;
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
.then(() => {
onRefresh();
@ -670,6 +671,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
return (
<div className={'recorder-row ' + recorder.status}>
{confirmModal}
<div className="recorder-preview">
{isRec && recorder.live_asset_id
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
@ -1600,6 +1602,7 @@ function Schedule({ navigate }) {
const [newDefaults, setNewDefaults] = React.useState(null);
const [editing, setEditing] = React.useState(null);
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
const [confirm, confirmModal] = window.useConfirm();
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
const [day, setDay] = React.useState(() => _dayStart(new Date()));
const [listFilter, setListFilter] = React.useState('upcoming');
@ -1676,12 +1679,12 @@ function Schedule({ navigate }) {
};
const openNewBlank = () => { setNewDefaults(null); setShowNew(true); };
const cancel = (s) => {
if (!confirm('Cancel scheduled recording "' + s.name + '"?')) return;
const cancel = async (s) => {
if (!(await confirm({ title: 'Cancel scheduled recording?', message: 'Cancel scheduled recording "' + s.name + '"?', confirmLabel: 'Cancel recording' }))) return;
window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }).then(load).catch(e => alert('Cancel failed: ' + e.message));
};
const remove = (s) => {
if (!confirm('Delete schedule "' + s.name + '"?')) return;
const remove = async (s) => {
if (!(await confirm({ title: 'Delete schedule?', message: 'Delete schedule "' + s.name + '"?' }))) return;
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message));
};
@ -1732,6 +1735,7 @@ function Schedule({ navigate }) {
return (
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
{confirmModal}
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
<div className="epg-toolbar">

View file

@ -42,6 +42,7 @@ function Jobs({ navigate }) {
const [tab, setTab] = React.useState('all');
const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
const [lastFetch, setLastFetch] = React.useState(Date.now());
const [confirm, confirmModal] = window.useConfirm();
const normalizeJob = (j) => {
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
@ -87,34 +88,47 @@ function Jobs({ navigate }) {
// stalled-active job (worker died mid-process, holding a concurrency slot)
// gets yanked and the next queued job runs. mode just changes the prompt
// copy so the operator knows what they're doing.
const handleDelete = React.useCallback((job, mode) => {
const handleDelete = React.useCallback(async (job, mode) => {
const msg = mode === 'cancel'
? 'Cancel this running ' + job.kind + ' job?\n\nThe worker may run a few seconds longer in the background, but its result will be discarded and the queue slot frees up immediately.'
: 'Remove this ' + job.status + ' ' + job.kind + ' job from the queue?';
if (!window.confirm(msg)) return;
if (!(await confirm({
title: mode === 'cancel' ? 'Cancel job?' : 'Remove job?',
message: msg,
confirmLabel: mode === 'cancel' ? 'Cancel job' : 'Remove',
}))) return;
window.ZAMPP_API.fetch('/jobs/' + job.id, { method: 'DELETE' })
.then(() => setJobs(prev => prev.filter(j => j.id !== job.id)))
.catch(e => alert((mode === 'cancel' ? 'Cancel' : 'Delete') + ' failed: ' + e.message));
}, []);
}, [confirm]);
// Retry every failed job at once. Useful after a transient infra issue
// (S3 outage, hung worker) - one click per job is painful with 20+ failures.
const handleRetryAll = React.useCallback(() => {
const handleRetryAll = React.useCallback(async () => {
const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return;
if (!window.confirm(`Re-queue all ${failedJobs.length} failed jobs?`)) return;
if (!(await confirm({
title: 'Re-queue failed jobs?',
message: `Re-queue all ${failedJobs.length} failed jobs?`,
confirmLabel: 'Re-queue',
danger: false,
}))) return;
Promise.allSettled(
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id + '/retry', { method: 'POST' }))
).then(refresh);
}, [jobs, refresh]);
}, [jobs, refresh, confirm]);
// Drop every failed job from the queue. The opposite of Retry all used
// when a batch of jobs is unrecoverable (e.g. assets that were deleted
// mid-encode) and the operator just wants the queue cleared.
const handleCancelAll = React.useCallback(() => {
const handleCancelAll = React.useCallback(async () => {
const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return;
if (!window.confirm(`Remove all ${failedJobs.length} failed jobs from the queue?\nThis cannot be undone.`)) return;
if (!(await confirm({
title: 'Remove all failed jobs?',
message: `Remove all ${failedJobs.length} failed jobs from the queue?\nThis cannot be undone.`,
confirmLabel: 'Remove all',
}))) return;
Promise.allSettled(
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id, { method: 'DELETE' }))
).then(() => {
@ -123,7 +137,7 @@ function Jobs({ navigate }) {
setJobs(prev => prev.filter(j => j.status !== 'failed'));
refresh();
});
}, [jobs, refresh]);
}, [jobs, refresh, confirm]);
const counts = {
all: jobs.length,
@ -136,6 +150,7 @@ function Jobs({ navigate }) {
return (
<div className="page">
{confirmModal}
<div className="page-header">
<h1>Jobs</h1>
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>

View file

@ -50,6 +50,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
const [renamingAsset, setRenamingAsset] = React.useState(null);
const [confirm, confirmModal] = window.useConfirm();
// Asset queued for hi-res download. Null means no modal showing. Set when
// the user clicks Download and has NOT dismissed the "are you sure" warning.
const [pendingDownload, setPendingDownload] = React.useState(null);
@ -85,6 +86,16 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
.catch(() => {});
}, []);
const deleteAsset = React.useCallback(async (asset) => {
if (!(await confirm({
title: 'Delete asset?',
message: 'Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.',
}))) return;
window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
.then(refreshAssets)
.catch(function(e) { alert('Delete failed: ' + e.message); });
}, [confirm, refreshAssets]);
// Auto-refresh: poll the library while it's open so live recordings flip
// to 'ready' (with thumbnail) without a manual reload. Also pull once on
// mount so uploads/imports created on other screens appear immediately.
@ -222,6 +233,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
return (
<div className="library-layout">
{confirmModal}
<aside className="library-rail">
<div>
<h4>Projects</h4>
@ -383,6 +395,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
onOpen={function() { onOpenAsset(ctxMenu.asset); }}
onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }}
onDownload={function(a) { setCtxMenu(null); requestDownload(a); }}
onDelete={function(a) { setCtxMenu(null); deleteAsset(a); }}
/>
)}
{pendingDownload && (
@ -466,7 +479,7 @@ function runDownload(asset) {
.catch(function(e) { alert('Download failed: ' + (e.message || 'unknown error')); });
}
function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename, onDownload }) {
function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRename, onDownload, onDelete }) {
const ref = React.useRef(null);
// Pin the menu inside the viewport even if the user right-clicked near
// the bottom-right edge of the grid.
@ -496,11 +509,8 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
};
const remove = function() {
if (onDelete) { onDelete(asset); return; }
onClose();
if (!confirm('Delete "' + (asset.display_name || asset.name) + '" permanently?\nThis removes the database row and the S3 objects.\nThis cannot be undone.')) return;
window.ZAMPP_API.fetch('/assets/' + asset.id + '?hard=true', { method: 'DELETE' })
.then(onChanged)
.catch(function(e) { alert('Delete failed: ' + e.message); });
};
return (

View file

@ -557,6 +557,7 @@ function ChannelDetail({ channel, onChannelChange }) {
const [items, setItems] = React.useState([]);
const [engine, setEngine] = React.useState(null);
const [ch, setCh] = React.useState(channel);
const [confirm, confirmModal] = window.useConfirm();
React.useEffect(() => { setCh(channel); }, [channel.id]);
@ -606,7 +607,7 @@ function ChannelDetail({ channel, onChannelChange }) {
setCh(updated); onChannelChange(updated);
};
const deleteChannel = async () => {
if (!window.confirm('Delete channel "' + ch.name + '"? This cannot be undone.')) return;
if (!(await confirm({ title: 'Delete channel?', message: 'Delete channel "' + ch.name + '"? This cannot be undone.' }))) return;
try {
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
onChannelChange({ ...ch, _deleted: true });
@ -618,6 +619,7 @@ function ChannelDetail({ channel, onChannelChange }) {
return (
<div className="po-detail">
{confirmModal}
<div className="po-detail-head">
<div>
<h3 style={{ margin: 0 }}>{ch.name}</h3>

View file

@ -49,6 +49,7 @@ function Projects({ onOpenProject, navigate }) {
const [showNew, setShowNew] = React.useState(false);
const [menuFor, setMenuFor] = React.useState(null);
const [renamingProject, setRenamingProject] = React.useState(null);
const [confirm, confirmModal] = window.useConfirm();
const [accessProject, setAccessProject] = React.useState(null);
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
@ -74,9 +75,9 @@ function Projects({ onOpenProject, navigate }) {
const renameProject = (p) => { setMenuFor(null); setRenamingProject(p); };
const deleteProject = (p) => {
const deleteProject = async (p) => {
setMenuFor(null);
if (!confirm('Delete project "' + p.name + '"?\nThis fails if there are still assets attached.')) return;
if (!(await confirm({ title: 'Delete project?', message: 'Delete project "' + p.name + '"?\nThis fails if there are still assets attached.' }))) return;
window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'DELETE' })
.then(refresh)
.catch(e => alert('Delete failed: ' + e.message));
@ -94,6 +95,7 @@ function Projects({ onOpenProject, navigate }) {
return (
<div className="page">
{confirmModal}
<div className="page-header">
<h1>Projects</h1>
<span className="subtitle">{filtered.length} projects</span>

View file

@ -583,6 +583,14 @@
animation: pulse 1.6s ease-in-out infinite;
}
.launcher-footer {
margin-top: 20px;
text-align: center;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-4);
}
/* ============================================================
Recorder row - signal indicator with a pulsing dot when
actually receiving frames. Closes part of #2.

View file

@ -137,4 +137,77 @@ function Elapsed({ seconds, live = false }) {
return <span className="mono">{String(h).padStart(2,'0')}:{String(m).padStart(2,'0')}:{String(s).padStart(2,'0')}</span>;
}
Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed });
//
// 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 });