Compare commits

...

2 commits

Author SHA1 Message Date
794b9d9929 fix(capture): growing file is MXF OP1a (DNxHR HQ) so Premiere can open it
The growing edit-while-record file was a fragmented MOV (empty moov), which
Premiere can't open ("Unable to open file on disk"). Write the growing master
as MXF OP1a / DNxHR HQ (Premiere-native, growable on disk); finalized master
keeps today's non-fragmented +faststart MOV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:31:07 -04:00
1d642bd437 feat(web-ui): in-page delete confirm modal + WDL home footer
Replace 17 native window.confirm() destructive prompts with an in-page
ConfirmModal/useConfirm (added to visuals.jsx) across jobs/asset/editor/
ingest/projects/admin/playout/library. Add "Created by Wild Dragon LLC"
footer to the home launcher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:31:07 -04:00
12 changed files with 230 additions and 54 deletions

View file

@ -155,12 +155,54 @@ const CONTAINER_EXT = {
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
};
// Growing-file (edit-while-record) master format.
//
// Premiere's "open capture in progress" / grow-on-disk support is FORMAT-
// SPECIFIC. A fragmented MP4/MOV (`+frag_keyframe+empty_moov+default_base_moof`)
// is NOT openable by Premiere as a growing file — its QuickTime importer needs
// the classic stco/stsz/stts sample tables in a single top-level moov, which a
// fragmented MOV never has while growing (samples live in moof/trun fragments).
// Symptom: "Unable to open file on disk." (Confirmed via ffprobe on zampp2: the
// growing .mov is ftyp + empty moov + repeating moof/mdat pairs, no sample
// tables.)
//
// The robust, broadcast-standard growing format Premiere DOES ingest is
// MXF OP1a (`-f mxf`) carrying a Premiere-native intra codec. We use DNxHR HQ
// (4:2:2 8-bit) which ffmpeg's MXF muxer accepts (HEVC/ProRes-in-MXF are
// rejected by this build), every frame is intra so a partially-written file is
// decodable to its last complete frame, and MXF writes header + body partitions
// incrementally so readers see valid essence mid-write. The same finalized .mxf
// is also a clean, Premiere-native asset, so the promotion/finalized path stays
// valid.
//
// Trade-off: DNxHR HQ is large (~22 GB/min at 1080p). Switch the profile to
// dnxhr_sq below (~half the bitrate) if disk is the constraint.
const GROWING_VIDEO_ARGS = [
'-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq', '-pix_fmt', 'yuv422p',
];
const GROWING_EXT = 'mxf';
function buildEncodeArgs({
codec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container, isNetwork, isProxy = false,
growing = false,
}) {
// ── Growing master: force MXF OP1a + DNxHR, ignoring the configured MOV/
// ProRes container/codec. This is the only combination Premiere opens as a
// growing file (see GROWING_VIDEO_ARGS above). Audio is forced to PCM,
// which MXF carries natively and Premiere ingests.
if (growing) {
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
args.push(...GROWING_VIDEO_ARGS);
if (framerate && framerate !== 'native') args.push('-r', framerate);
args.push('-c:a', 'pcm_s24le');
if (audioChannels) args.push('-ac', String(audioChannels));
args.push('-f', 'mxf');
return args;
}
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
@ -180,19 +222,15 @@ function buildEncodeArgs({
// moov-atom placement is the difference between a Premiere-openable master and
// a "file cannot be opened" error.
//
// - Growing-file masters (edit-while-record on the SMB share) MUST be
// fragmented so a moov/mvex is present from the first frame and the file is
// decodable while still being written. The samples live in moof/trun boxes.
//
// - Finalized masters (the S3-piped recording that stops cleanly) must NOT be
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
// opened." We write a clean, non-fragmented MOV instead.
// `+faststart` puts the moov before mdat on the second pass so the file is
// instantly seekable/streamable too.
// Finalized masters (the S3-piped recording that stops cleanly) must NOT be
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
// opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
// moov before mdat on the second pass so the file is instantly
// seekable/streamable too.
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', growing ? '+frag_keyframe+empty_moov+default_base_moof' : '+faststart');
args.push('-movflags', '+faststart');
}
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
args.push('-f', fmt);
@ -384,8 +422,11 @@ class CaptureManager {
if (growingActive && GROWING_SMB_MOUNT) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
// Growing master is always MXF OP1a (the only Premiere-growable format here),
// regardless of the recorder's configured container — so it gets a .mxf
// extension, not hiresExt.
const growingPath = growingActive
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
? `${GROWING_PATH}/${projectId}/${clipName}.${GROWING_EXT}`
: null;
if (growingPath) {
try { mkdirSync(dirname(growingPath), { recursive: true }); }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -137,4 +137,77 @@ function Elapsed({ seconds, live = false }) {
return <span className="mono">{String(h).padStart(2,'0')}:{String(m).padStart(2,'0')}:{String(s).padStart(2,'0')}</span>;
}
Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed });
//
// ConfirmModal + useConfirm in-page replacement for window.confirm().
//
// Usage in a component:
// const [confirm, confirmModal] = useConfirm();
// ...
// if (!(await confirm({ title: 'Delete user?', message: '' }))) return;
// ...
// return (<>{confirmModal} ...rest of UI... </>);
//
// confirm(opts) returns a Promise<boolean>. Options:
// title, message, confirmLabel (default 'Delete'), cancelLabel ('Cancel'),
// danger (default true red confirm button).
function ConfirmModal({ title, message, confirmLabel = 'Delete', cancelLabel = 'Cancel', danger = true, onConfirm, onCancel }) {
React.useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onCancel();
else if (e.key === 'Enter') onConfirm();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onConfirm, onCancel]);
return (
<div className="modal-backdrop" onClick={onCancel}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>{title}</div>
<button className="icon-btn" aria-label="Close" onClick={onCancel}><Icon name="x" /></button>
</div>
<div className="modal-body">
{typeof message === 'string'
? message.split('\n').map((line, i) => (
<div key={i} style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.5 }}>{line || ' '}</div>
))
: <div style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.5 }}>{message}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onCancel}>{cancelLabel}</button>
<button className={danger ? 'btn danger' : 'btn primary'} onClick={onConfirm} autoFocus>{confirmLabel}</button>
</div>
</div>
</div>
);
}
function useConfirm() {
const [state, setState] = React.useState(null); // { opts, resolve } | null
const confirm = React.useCallback((opts) => {
return new Promise((resolve) => {
setState({ opts: opts || {}, resolve });
});
}, []);
const close = React.useCallback((result) => {
setState((s) => {
if (s) s.resolve(result);
return null;
});
}, []);
const modal = state
? <ConfirmModal
{...state.opts}
onConfirm={() => close(true)}
onCancel={() => close(false)}
/>
: null;
return [confirm, modal];
}
Object.assign(window, { AssetThumb, FauxFrame, Waveform, LiveStrip, Sparkline, AudioMeter, StatusDot, Elapsed, ConfirmModal, useConfirm });