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 [editingUser, setEditingUser] = React.useState(null);
|
||||||
const [resetUser, setResetUser] = React.useState(null);
|
const [resetUser, setResetUser] = React.useState(null);
|
||||||
const [menuFor, setMenuFor] = React.useState(null); // row id whose menu is open
|
const [menuFor, setMenuFor] = React.useState(null); // row id whose menu is open
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
const refreshUsers = React.useCallback(() => {
|
const refreshUsers = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/users')
|
window.ZAMPP_API.fetch('/users')
|
||||||
|
|
@ -169,9 +170,9 @@ function Users() {
|
||||||
|
|
||||||
const onCreated = () => { refreshUsers(); setShowInvite(false); };
|
const onCreated = () => { refreshUsers(); setShowInvite(false); };
|
||||||
|
|
||||||
const deleteUser = (u) => {
|
const deleteUser = async (u) => {
|
||||||
setMenuFor(null);
|
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' })
|
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'DELETE' })
|
||||||
.then(refreshUsers)
|
.then(refreshUsers)
|
||||||
.catch(e => alert('Delete failed: ' + e.message));
|
.catch(e => alert('Delete failed: ' + e.message));
|
||||||
|
|
@ -188,6 +189,7 @@ function Users() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
{confirmModal}
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Users & Groups</h1>
|
<h1>Users & Groups</h1>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
|
|
@ -296,6 +298,7 @@ function Users() {
|
||||||
function PoliciesPanel({ users, onChange }) {
|
function PoliciesPanel({ users, onChange }) {
|
||||||
const [expandedId, setExpandedId] = React.useState(null);
|
const [expandedId, setExpandedId] = React.useState(null);
|
||||||
const [err, setErr] = React.useState(null);
|
const [err, setErr] = React.useState(null);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
const changeRole = (u, newRole) => {
|
const changeRole = (u, newRole) => {
|
||||||
if (u.role === newRole) return;
|
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
|
// Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
|
||||||
// body). Mirrors the disable() pattern in TotpSection.
|
// body). Mirrors the disable() pattern in TotpSection.
|
||||||
const resetTotp = (u) => {
|
const resetTotp = async (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;
|
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);
|
setErr(null);
|
||||||
fetch('/api/v1/users/' + u.id + '/totp/disable', {
|
fetch('/api/v1/users/' + u.id + '/totp/disable', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -324,6 +327,7 @@ function PoliciesPanel({ users, onChange }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{confirmModal}
|
||||||
{/* Access-model explainer (kept from the old static tab, condensed) */}
|
{/* Access-model explainer (kept from the old static tab, condensed) */}
|
||||||
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
|
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
<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 [newDesc, setNewDesc] = React.useState('');
|
||||||
const [expandedId, setExpandedId] = React.useState(null);
|
const [expandedId, setExpandedId] = React.useState(null);
|
||||||
const [members, setMembers] = React.useState({}); // groupId -> [user]
|
const [members, setMembers] = React.useState({}); // groupId -> [user]
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
const createGroup = () => {
|
const createGroup = () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
|
|
@ -617,8 +622,8 @@ function GroupsPanel({ groups, users, onChange }) {
|
||||||
.catch(e => alert('Create failed: ' + e.message));
|
.catch(e => alert('Create failed: ' + e.message));
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteGroup = (g) => {
|
const deleteGroup = async (g) => {
|
||||||
if (!confirm(`Delete group "${g.name}"?`)) return;
|
if (!(await confirm({ title: 'Delete group?', message: `Delete group "${g.name}"?` }))) return;
|
||||||
window.ZAMPP_API.fetch('/groups/' + g.id, { method: 'DELETE' })
|
window.ZAMPP_API.fetch('/groups/' + g.id, { method: 'DELETE' })
|
||||||
.then(onChange)
|
.then(onChange)
|
||||||
.catch(e => alert('Delete failed: ' + e.message));
|
.catch(e => alert('Delete failed: ' + e.message));
|
||||||
|
|
@ -653,6 +658,7 @@ function GroupsPanel({ groups, users, onChange }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{confirmModal}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
|
||||||
<div style={{ flex: 1, fontSize: 12, color: 'var(--text-3)' }}>
|
<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.
|
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 [containers, setContainers] = React.useState(null);
|
||||||
const [restartFlashState, setRestartFlashState] = React.useState(null);
|
const [restartFlashState, setRestartFlashState] = React.useState(null);
|
||||||
const [logsModalState, setLogsModalState] = React.useState(null);
|
const [logsModalState, setLogsModalState] = React.useState(null);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
// #111 - guard restart-flash timers against unmount.
|
// #111 - guard restart-flash timers against unmount.
|
||||||
const mountedRef = React.useRef(true);
|
const mountedRef = React.useRef(true);
|
||||||
const flashTimerRef = React.useRef(null);
|
const flashTimerRef = React.useRef(null);
|
||||||
|
|
@ -1045,8 +1052,8 @@ function Containers() {
|
||||||
|
|
||||||
const showLogs = (c) => setLogsModal(c);
|
const showLogs = (c) => setLogsModal(c);
|
||||||
|
|
||||||
const restartContainer = (c) => {
|
const restartContainer = async (c) => {
|
||||||
if (!window.confirm('Restart container "' + c.name + '"?\nIn-flight requests will be dropped.')) return;
|
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' });
|
setRestartFlashSafe({ name: c.name, status: 'pending' });
|
||||||
window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' })
|
window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|
@ -1063,6 +1070,7 @@ function Containers() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
{confirmModal}
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Containers</h1>
|
<h1>Containers</h1>
|
||||||
<span className="subtitle">Docker Compose services across the cluster</span>
|
<span className="subtitle">Docker Compose services across the cluster</span>
|
||||||
|
|
@ -1438,6 +1446,7 @@ function Cluster() {
|
||||||
const [hovered, setHovered] = React.useState(null);
|
const [hovered, setHovered] = React.useState(null);
|
||||||
// Map of "node_id:portIndex" → signal entry from /cluster/devices/blackmagic/signal
|
// Map of "node_id:portIndex" → signal entry from /cluster/devices/blackmagic/signal
|
||||||
const [portSignals, setPortSignals] = React.useState({});
|
const [portSignals, setPortSignals] = React.useState({});
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
const refresh = React.useCallback(() => {
|
const refresh = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/cluster')
|
window.ZAMPP_API.fetch('/cluster')
|
||||||
|
|
@ -1522,8 +1531,8 @@ function Cluster() {
|
||||||
commands: [`ssh ${node.ip || node.id} 'docker stop node-agent'`],
|
commands: [`ssh ${node.ip || node.id} 'docker stop node-agent'`],
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeNode = (node) => {
|
const removeNode = async (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;
|
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' })
|
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' })
|
||||||
.then(() => refresh())
|
.then(() => refresh())
|
||||||
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
|
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
|
||||||
|
|
@ -1537,6 +1546,7 @@ function Cluster() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
{confirmModal}
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Cluster</h1>
|
<h1>Cluster</h1>
|
||||||
<span className="subtitle">{NODES.filter(n => n.status === "online").length} of {NODES.length} nodes online</span>
|
<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 fileRef = React.useRef(null);
|
||||||
const [uploading, setUploading] = React.useState(false);
|
const [uploading, setUploading] = React.useState(false);
|
||||||
const [progress, setProgress] = React.useState(0);
|
const [progress, setProgress] = React.useState(0);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
const deployed = status && status.file_count > 0;
|
const deployed = status && status.file_count > 0;
|
||||||
const lastUpload = status?.uploaded_at
|
const lastUpload = status?.uploaded_at
|
||||||
|
|
@ -2774,8 +2785,8 @@ function SdkVendorRow({ vendor, status, onDone }) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const clear = () => {
|
const clear = async () => {
|
||||||
if (!confirm('Remove staged ' + vendor.name + ' SDK files?')) return;
|
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' })
|
window.ZAMPP_API.fetch('/sdk/' + vendor.id, { method: 'DELETE' })
|
||||||
.then(() => onDone(vendor.name + ': cleared.', true))
|
.then(() => onDone(vendor.name + ': cleared.', true))
|
||||||
.catch(e => onDone(vendor.name + ': ' + e.message, false));
|
.catch(e => onDone(vendor.name + ': ' + e.message, false));
|
||||||
|
|
@ -2783,6 +2794,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid var(--border)', borderRadius: 6, padding: 12, background: 'var(--bg-2)' }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
<strong style={{ fontSize: 13 }}>{vendor.name}</strong>
|
<strong style={{ fontSize: 13 }}>{vendor.name}</strong>
|
||||||
{deployed
|
{deployed
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
const [comments, setComments] = React.useState([]);
|
const [comments, setComments] = React.useState([]);
|
||||||
const [newComment, setNewComment] = React.useState("");
|
const [newComment, setNewComment] = React.useState("");
|
||||||
const [commentsLoading, setCommentsLoading] = React.useState(false);
|
const [commentsLoading, setCommentsLoading] = React.useState(false);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
// Stream / video state
|
// Stream / video state
|
||||||
const [streamUrl, setStreamUrl] = React.useState(null);
|
const [streamUrl, setStreamUrl] = React.useState(null);
|
||||||
|
|
@ -231,9 +232,12 @@ function AssetDetail({ asset, onClose }) {
|
||||||
if (navigator.clipboard) navigator.clipboard.writeText(assetId).catch(function() {});
|
if (navigator.clipboard) navigator.clipboard.writeText(assetId).catch(function() {});
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
};
|
};
|
||||||
const deleteAsset = function() {
|
const deleteAsset = async function() {
|
||||||
setMenuOpen(false);
|
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' })
|
window.ZAMPP_API.fetch('/assets/' + assetId + '?hard=true', { method: 'DELETE' })
|
||||||
.then(function() { onClose && onClose(); })
|
.then(function() { onClose && onClose(); })
|
||||||
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
.catch(function(e) { window.alert('Delete failed: ' + e.message); });
|
||||||
|
|
@ -325,8 +329,8 @@ function AssetDetail({ asset, onClose }) {
|
||||||
.catch(function() {});
|
.catch(function() {});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteComment = function(c) {
|
const deleteComment = async function(c) {
|
||||||
if (!confirm('Delete this comment?')) return;
|
if (!(await confirm({ title: 'Delete comment?', message: 'Delete this comment?' }))) return;
|
||||||
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'DELETE' })
|
window.ZAMPP_API.fetch('/assets/' + assetId + '/comments/' + c.id, { method: 'DELETE' })
|
||||||
.then(function() {
|
.then(function() {
|
||||||
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
|
setComments(function(prev) { return prev.filter(x => x.id !== c.id); });
|
||||||
|
|
@ -355,6 +359,7 @@ function AssetDetail({ asset, onClose }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="asset-detail fade-in">
|
<div className="asset-detail fade-in">
|
||||||
|
{confirmModal}
|
||||||
<div className="asset-detail-header">
|
<div className="asset-detail-header">
|
||||||
<button className="icon-btn" aria-label="Back" onClick={onClose}><Icon name="arrowLeft" /></button>
|
<button className="icon-btn" aria-label="Back" onClick={onClose}><Icon name="arrowLeft" /></button>
|
||||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ function Editor() {
|
||||||
const [projectId, setProjectId] = React.useState(null);
|
const [projectId, setProjectId] = React.useState(null);
|
||||||
const [sequences, setSequences] = React.useState([]);
|
const [sequences, setSequences] = React.useState([]);
|
||||||
const [currentSeq, setCurrentSeq] = React.useState(null);
|
const [currentSeq, setCurrentSeq] = React.useState(null);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
const [assets, setAssets] = React.useState([]);
|
const [assets, setAssets] = React.useState([]);
|
||||||
const [bins, setBins] = React.useState([]);
|
const [bins, setBins] = React.useState([]);
|
||||||
const [sourceAsset, setSourceAsset] = React.useState(null);
|
const [sourceAsset, setSourceAsset] = React.useState(null);
|
||||||
|
|
@ -159,7 +160,7 @@ function Editor() {
|
||||||
|
|
||||||
async function deleteSequence() {
|
async function deleteSequence() {
|
||||||
if (!currentSeq) return;
|
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 {
|
try {
|
||||||
await window.ZAMPP_API.deleteSequence(currentSeq.id);
|
await window.ZAMPP_API.deleteSequence(currentSeq.id);
|
||||||
const remaining = sequences.filter(s => s.id !== currentSeq.id);
|
const remaining = sequences.filter(s => s.id !== currentSeq.id);
|
||||||
|
|
@ -377,6 +378,7 @@ function Editor() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
<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. */}
|
{/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
|
||||||
<div className="editor-beta-banner">
|
<div className="editor-beta-banner">
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,8 @@ function Home({ navigate }) {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="launcher-footer">Created by Wild Dragon LLC</div>
|
||||||
</div>
|
</div>
|
||||||
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
|
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
const [clipName, setClipName] = React.useState('');
|
const [clipName, setClipName] = React.useState('');
|
||||||
// Project override for this take. Defaults to the recorder's configured project.
|
// Project override for this take. Defaults to the recorder's configured project.
|
||||||
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
|
const [takeProjectId, setTakeProjectId] = React.useState(initialRecorder.project_id || PROJECTS[0]?.id || '');
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
const isRec = recorder.status === 'recording';
|
const isRec = recorder.status === 'recording';
|
||||||
|
|
||||||
// Keep takeProjectId in sync if the recorder row changes (e.g. after a refresh).
|
// 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); });
|
.catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = async () => {
|
||||||
if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return;
|
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' })
|
window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
onRefresh();
|
onRefresh();
|
||||||
|
|
@ -670,6 +671,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'recorder-row ' + recorder.status}>
|
<div className={'recorder-row ' + recorder.status}>
|
||||||
|
{confirmModal}
|
||||||
<div className="recorder-preview">
|
<div className="recorder-preview">
|
||||||
{isRec && recorder.live_asset_id
|
{isRec && recorder.live_asset_id
|
||||||
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
|
? <HlsPreview assetId={recorder.live_asset_id} recorderId={recorder.id} />
|
||||||
|
|
@ -1600,6 +1602,7 @@ function Schedule({ navigate }) {
|
||||||
const [newDefaults, setNewDefaults] = React.useState(null);
|
const [newDefaults, setNewDefaults] = React.useState(null);
|
||||||
const [editing, setEditing] = React.useState(null);
|
const [editing, setEditing] = React.useState(null);
|
||||||
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
|
const [ctxMenu, setCtxMenu] = React.useState(null); // { schedule, x, y }
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
|
const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list'
|
||||||
const [day, setDay] = React.useState(() => _dayStart(new Date()));
|
const [day, setDay] = React.useState(() => _dayStart(new Date()));
|
||||||
const [listFilter, setListFilter] = React.useState('upcoming');
|
const [listFilter, setListFilter] = React.useState('upcoming');
|
||||||
|
|
@ -1676,12 +1679,12 @@ function Schedule({ navigate }) {
|
||||||
};
|
};
|
||||||
const openNewBlank = () => { setNewDefaults(null); setShowNew(true); };
|
const openNewBlank = () => { setNewDefaults(null); setShowNew(true); };
|
||||||
|
|
||||||
const cancel = (s) => {
|
const cancel = async (s) => {
|
||||||
if (!confirm('Cancel scheduled recording "' + s.name + '"?')) return;
|
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));
|
window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }).then(load).catch(e => alert('Cancel failed: ' + e.message));
|
||||||
};
|
};
|
||||||
const remove = (s) => {
|
const remove = async (s) => {
|
||||||
if (!confirm('Delete schedule "' + s.name + '"?')) return;
|
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));
|
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 (
|
return (
|
||||||
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
|
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
|
||||||
|
{confirmModal}
|
||||||
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
|
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
|
||||||
|
|
||||||
<div className="epg-toolbar">
|
<div className="epg-toolbar">
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ function Jobs({ navigate }) {
|
||||||
const [tab, setTab] = React.useState('all');
|
const [tab, setTab] = React.useState('all');
|
||||||
const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
|
const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
|
||||||
const [lastFetch, setLastFetch] = React.useState(Date.now());
|
const [lastFetch, setLastFetch] = React.useState(Date.now());
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
const normalizeJob = (j) => {
|
const normalizeJob = (j) => {
|
||||||
const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' };
|
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)
|
// stalled-active job (worker died mid-process, holding a concurrency slot)
|
||||||
// gets yanked and the next queued job runs. mode just changes the prompt
|
// gets yanked and the next queued job runs. mode just changes the prompt
|
||||||
// copy so the operator knows what they're doing.
|
// 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'
|
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.'
|
? '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?';
|
: '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' })
|
window.ZAMPP_API.fetch('/jobs/' + job.id, { method: 'DELETE' })
|
||||||
.then(() => setJobs(prev => prev.filter(j => j.id !== job.id)))
|
.then(() => setJobs(prev => prev.filter(j => j.id !== job.id)))
|
||||||
.catch(e => alert((mode === 'cancel' ? 'Cancel' : 'Delete') + ' failed: ' + e.message));
|
.catch(e => alert((mode === 'cancel' ? 'Cancel' : 'Delete') + ' failed: ' + e.message));
|
||||||
}, []);
|
}, [confirm]);
|
||||||
|
|
||||||
// Retry every failed job at once. Useful after a transient infra issue
|
// 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.
|
// (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');
|
const failedJobs = jobs.filter(j => j.status === 'failed');
|
||||||
if (failedJobs.length === 0) return;
|
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(
|
Promise.allSettled(
|
||||||
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id + '/retry', { method: 'POST' }))
|
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id + '/retry', { method: 'POST' }))
|
||||||
).then(refresh);
|
).then(refresh);
|
||||||
}, [jobs, refresh]);
|
}, [jobs, refresh, confirm]);
|
||||||
|
|
||||||
// Drop every failed job from the queue. The opposite of Retry all — used
|
// 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
|
// when a batch of jobs is unrecoverable (e.g. assets that were deleted
|
||||||
// mid-encode) and the operator just wants the queue cleared.
|
// 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');
|
const failedJobs = jobs.filter(j => j.status === 'failed');
|
||||||
if (failedJobs.length === 0) return;
|
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(
|
Promise.allSettled(
|
||||||
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id, { method: 'DELETE' }))
|
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id, { method: 'DELETE' }))
|
||||||
).then(() => {
|
).then(() => {
|
||||||
|
|
@ -123,7 +137,7 @@ function Jobs({ navigate }) {
|
||||||
setJobs(prev => prev.filter(j => j.status !== 'failed'));
|
setJobs(prev => prev.filter(j => j.status !== 'failed'));
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
}, [jobs, refresh]);
|
}, [jobs, refresh, confirm]);
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
all: jobs.length,
|
all: jobs.length,
|
||||||
|
|
@ -136,6 +150,7 @@ function Jobs({ navigate }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
{confirmModal}
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Jobs</h1>
|
<h1>Jobs</h1>
|
||||||
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
|
<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 [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
|
||||||
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
|
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
|
||||||
const [renamingAsset, setRenamingAsset] = React.useState(null);
|
const [renamingAsset, setRenamingAsset] = React.useState(null);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
// Asset queued for hi-res download. Null means no modal showing. Set when
|
// 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.
|
// the user clicks Download and has NOT dismissed the "are you sure" warning.
|
||||||
const [pendingDownload, setPendingDownload] = React.useState(null);
|
const [pendingDownload, setPendingDownload] = React.useState(null);
|
||||||
|
|
@ -85,6 +86,16 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
.catch(() => {});
|
.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
|
// 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
|
// to 'ready' (with thumbnail) without a manual reload. Also pull once on
|
||||||
// mount so uploads/imports created on other screens appear immediately.
|
// mount so uploads/imports created on other screens appear immediately.
|
||||||
|
|
@ -222,6 +233,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="library-layout">
|
<div className="library-layout">
|
||||||
|
{confirmModal}
|
||||||
<aside className="library-rail">
|
<aside className="library-rail">
|
||||||
<div>
|
<div>
|
||||||
<h4>Projects</h4>
|
<h4>Projects</h4>
|
||||||
|
|
@ -383,6 +395,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
|
||||||
onOpen={function() { onOpenAsset(ctxMenu.asset); }}
|
onOpen={function() { onOpenAsset(ctxMenu.asset); }}
|
||||||
onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }}
|
onRename={function(a) { setCtxMenu(null); setRenamingAsset(a); }}
|
||||||
onDownload={function(a) { setCtxMenu(null); requestDownload(a); }}
|
onDownload={function(a) { setCtxMenu(null); requestDownload(a); }}
|
||||||
|
onDelete={function(a) { setCtxMenu(null); deleteAsset(a); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pendingDownload && (
|
{pendingDownload && (
|
||||||
|
|
@ -466,7 +479,7 @@ function runDownload(asset) {
|
||||||
.catch(function(e) { alert('Download failed: ' + (e.message || 'unknown error')); });
|
.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);
|
const ref = React.useRef(null);
|
||||||
// Pin the menu inside the viewport even if the user right-clicked near
|
// Pin the menu inside the viewport even if the user right-clicked near
|
||||||
// the bottom-right edge of the grid.
|
// the bottom-right edge of the grid.
|
||||||
|
|
@ -496,11 +509,8 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = function() {
|
const remove = function() {
|
||||||
|
if (onDelete) { onDelete(asset); return; }
|
||||||
onClose();
|
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 (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -557,6 +557,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
const [items, setItems] = React.useState([]);
|
const [items, setItems] = React.useState([]);
|
||||||
const [engine, setEngine] = React.useState(null);
|
const [engine, setEngine] = React.useState(null);
|
||||||
const [ch, setCh] = React.useState(channel);
|
const [ch, setCh] = React.useState(channel);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
|
|
||||||
React.useEffect(() => { setCh(channel); }, [channel.id]);
|
React.useEffect(() => { setCh(channel); }, [channel.id]);
|
||||||
|
|
||||||
|
|
@ -606,7 +607,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
setCh(updated); onChannelChange(updated);
|
setCh(updated); onChannelChange(updated);
|
||||||
};
|
};
|
||||||
const deleteChannel = async () => {
|
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 {
|
try {
|
||||||
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
|
await poFetch('/channels/' + ch.id, { method: 'DELETE' });
|
||||||
onChannelChange({ ...ch, _deleted: true });
|
onChannelChange({ ...ch, _deleted: true });
|
||||||
|
|
@ -618,6 +619,7 @@ function ChannelDetail({ channel, onChannelChange }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="po-detail">
|
<div className="po-detail">
|
||||||
|
{confirmModal}
|
||||||
<div className="po-detail-head">
|
<div className="po-detail-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>{ch.name}</h3>
|
<h3 style={{ margin: 0 }}>{ch.name}</h3>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
const [showNew, setShowNew] = React.useState(false);
|
const [showNew, setShowNew] = React.useState(false);
|
||||||
const [menuFor, setMenuFor] = React.useState(null);
|
const [menuFor, setMenuFor] = React.useState(null);
|
||||||
const [renamingProject, setRenamingProject] = React.useState(null);
|
const [renamingProject, setRenamingProject] = React.useState(null);
|
||||||
|
const [confirm, confirmModal] = window.useConfirm();
|
||||||
const [accessProject, setAccessProject] = React.useState(null);
|
const [accessProject, setAccessProject] = React.useState(null);
|
||||||
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
|
const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin';
|
||||||
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
|
const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); };
|
||||||
|
|
@ -74,9 +75,9 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
|
|
||||||
const renameProject = (p) => { setMenuFor(null); setRenamingProject(p); };
|
const renameProject = (p) => { setMenuFor(null); setRenamingProject(p); };
|
||||||
|
|
||||||
const deleteProject = (p) => {
|
const deleteProject = async (p) => {
|
||||||
setMenuFor(null);
|
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' })
|
window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'DELETE' })
|
||||||
.then(refresh)
|
.then(refresh)
|
||||||
.catch(e => alert('Delete failed: ' + e.message));
|
.catch(e => alert('Delete failed: ' + e.message));
|
||||||
|
|
@ -94,6 +95,7 @@ function Projects({ onOpenProject, navigate }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
|
{confirmModal}
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>Projects</h1>
|
<h1>Projects</h1>
|
||||||
<span className="subtitle">{filtered.length} projects</span>
|
<span className="subtitle">{filtered.length} projects</span>
|
||||||
|
|
|
||||||
|
|
@ -583,6 +583,14 @@
|
||||||
animation: pulse 1.6s ease-in-out infinite;
|
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
|
Recorder row - signal indicator with a pulsing dot when
|
||||||
actually receiving frames. Closes part of #2.
|
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>;
|
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