Compare commits
2 commits
fffff1c016
...
794b9d9929
| Author | SHA1 | Date | |
|---|---|---|---|
| 794b9d9929 | |||
| 1d642bd437 |
12 changed files with 230 additions and 54 deletions
|
|
@ -155,12 +155,54 @@ const CONTAINER_EXT = {
|
||||||
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
|
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({
|
function buildEncodeArgs({
|
||||||
codec, videoBitrate, framerate,
|
codec, videoBitrate, framerate,
|
||||||
audioCodec, audioBitrate, audioChannels,
|
audioCodec, audioBitrate, audioChannels,
|
||||||
container, isNetwork, isProxy = false,
|
container, isNetwork, isProxy = false,
|
||||||
growing = 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 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 a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
|
||||||
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
|
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
|
// moov-atom placement is the difference between a Premiere-openable master and
|
||||||
// a "file cannot be opened" error.
|
// a "file cannot be opened" error.
|
||||||
//
|
//
|
||||||
// - Growing-file masters (edit-while-record on the SMB share) MUST be
|
// Finalized masters (the S3-piped recording that stops cleanly) must NOT 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
|
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
|
||||||
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
|
// 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
|
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
|
||||||
// opened." We write a clean, non-fragmented MOV instead.
|
// opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
|
||||||
// `+faststart` puts the moov before mdat on the second pass so the file is
|
// moov before mdat on the second pass so the file is instantly
|
||||||
// instantly seekable/streamable too.
|
// seekable/streamable too.
|
||||||
if (fmt === 'mov' || fmt === 'mp4') {
|
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.
|
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
|
||||||
args.push('-f', fmt);
|
args.push('-f', fmt);
|
||||||
|
|
@ -384,8 +422,11 @@ class CaptureManager {
|
||||||
if (growingActive && GROWING_SMB_MOUNT) {
|
if (growingActive && GROWING_SMB_MOUNT) {
|
||||||
if (!mountGrowingShare()) growingActive = false; // fall back to S3
|
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
|
const growingPath = growingActive
|
||||||
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
|
? `${GROWING_PATH}/${projectId}/${clipName}.${GROWING_EXT}`
|
||||||
: null;
|
: null;
|
||||||
if (growingPath) {
|
if (growingPath) {
|
||||||
try { mkdirSync(dirname(growingPath), { recursive: true }); }
|
try { mkdirSync(dirname(growingPath), { recursive: true }); }
|
||||||
|
|
|
||||||
|
|
@ -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