diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 522fa2d..64c5a61 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -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 (
+ {confirmModal}

Users & Groups

@@ -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 (
+ {confirmModal} {/* Access-model explainer (kept from the old static tab, condensed) */}
@@ -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 (
+ {confirmModal}
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 (
+ {confirmModal}

Containers

Docker Compose services across the cluster @@ -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 (
+ {confirmModal}

Cluster

{NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online @@ -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 (
+ {confirmModal}
{vendor.name} {deployed diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index a6beafb..5226898 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -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 (
+ {confirmModal}
diff --git a/services/web-ui/public/screens-editor.jsx b/services/web-ui/public/screens-editor.jsx index 225a1cb..69040e0 100644 --- a/services/web-ui/public/screens-editor.jsx +++ b/services/web-ui/public/screens-editor.jsx @@ -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 (
+ {confirmModal} {/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx index ea72f93..4e09530 100644 --- a/services/web-ui/public/screens-home.jsx +++ b/services/web-ui/public/screens-home.jsx @@ -284,6 +284,8 @@ function Home({ navigate }) { )}
+ +
Created by Wild Dragon LLC
{showDownloads && setShowDownloads(false)} />}
diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 09c8369..48e92f0 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -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 (
+ {confirmModal}
{isRec && recorder.live_asset_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 (
+ {confirmModal} <_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
diff --git a/services/web-ui/public/screens-jobs.jsx b/services/web-ui/public/screens-jobs.jsx index cbdd363..5752851 100644 --- a/services/web-ui/public/screens-jobs.jsx +++ b/services/web-ui/public/screens-jobs.jsx @@ -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 (
+ {confirmModal}

Jobs

Proxy generation, transcoding, and processing queue diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index b751b5f..17a4bc0 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -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 (
+ {confirmModal}