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:
parent
fffff1c016
commit
1d642bd437
11 changed files with 176 additions and 41 deletions
|
|
@ -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 & 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
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue