download is used by the Downloads section tile on the home screen. YouTube ingest gets the new import icon (arrow entering box) instead.
476 lines
18 KiB
JavaScript
476 lines
18 KiB
JavaScript
// shell.jsx - app shell: sidebar nav, topbar, route container
|
|
|
|
// Sidebar IA: grouped sections. Renderer prints each section's label, then
|
|
// its items. Items inside `children` of a `group:true` node still render as
|
|
// the existing expandable submenu (only used for the Capture-SDK admin tools
|
|
// today, but kept general).
|
|
const NAV_SECTIONS = [
|
|
{
|
|
label: "Workspace",
|
|
items: [
|
|
{ id: "home", label: "Home", icon: "home" },
|
|
{ id: "dashboard", label: "Dashboard", icon: "layout" },
|
|
{ id: "projects", label: "Projects", icon: "folder" },
|
|
{ id: "library", label: "Library", icon: "library" },
|
|
],
|
|
},
|
|
{
|
|
label: "Ingest",
|
|
items: [
|
|
{ id: "upload", label: "Upload", icon: "upload" },
|
|
{ id: "youtube", label: "YouTube", icon: "import" },
|
|
{ id: "recorders", label: "Recorders", icon: "record" },
|
|
{ id: "schedule", label: "Schedule", icon: "clock" },
|
|
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
|
],
|
|
},
|
|
{
|
|
label: "Operations",
|
|
items: [
|
|
{ id: "capture", label: "Capture", icon: "capture" },
|
|
{ id: "playout", label: "Playout", icon: "signal" },
|
|
{ id: "jobs", label: "Jobs", icon: "jobs" },
|
|
],
|
|
},
|
|
{
|
|
label: "Admin",
|
|
items: [
|
|
{ id: "users", label: "Users", icon: "users" },
|
|
{ id: "tokens", label: "Tokens", icon: "token" },
|
|
{ id: "billing", label: "Billing", icon: "dollar" },
|
|
{ id: "containers", label: "Containers", icon: "container" },
|
|
{ id: "cluster", label: "Cluster", icon: "cluster" },
|
|
{ id: "settings", label: "Settings", icon: "settings" },
|
|
],
|
|
},
|
|
];
|
|
|
|
// No hidden routes currently; Billing (the satirical pricing page) lives in
|
|
// the Admin section above. Real API token management is at /tokens.
|
|
const NAV_HIDDEN = [];
|
|
|
|
// Back-compat: NAV_TREE and ADMIN_TREE were used by other modules.
|
|
// NAV_FLAT is consumed by topbar search and the keyboard router.
|
|
const NAV_TREE = NAV_SECTIONS.slice(0, 3).flatMap(s => s.items);
|
|
const ADMIN_TREE = NAV_SECTIONS[3].items;
|
|
const NAV_FLAT = (() => {
|
|
const out = [];
|
|
NAV_SECTIONS.forEach(s => s.items.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon })));
|
|
NAV_HIDDEN.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon }));
|
|
return out;
|
|
})();
|
|
|
|
function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) {
|
|
const isGroup = item.group && item.children;
|
|
const isOpen = isGroup && openGroups.has(item.id);
|
|
const isActive = active === item.id || (isGroup && item.children.some(c => c.id === active));
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
|
|
data-tip={item.label}
|
|
onClick={() => {
|
|
if (isGroup) toggleGroup(item.id);
|
|
else onSelect(item.id);
|
|
}}
|
|
>
|
|
<Icon name={item.icon} size={15} className="nav-icon" />
|
|
<span>{item.label}</span>
|
|
{item.badge && (
|
|
<span className={`nav-badge ${item.badge.kind}`}>{item.badge.text}</span>
|
|
)}
|
|
{isGroup && <Icon name="chevron" size={12} className="nav-caret" />}
|
|
</div>
|
|
{isGroup && isOpen && (
|
|
<div className="nav-children">
|
|
{item.children.map(c => (
|
|
<div
|
|
key={c.id}
|
|
className={`nav-item ${active === c.id ? "active" : ""}`}
|
|
onClick={() => onSelect(c.id)}
|
|
>
|
|
<Icon name={c.icon} size={14} className="nav-icon" />
|
|
<span>{c.label}</span>
|
|
{c.badge && <span className={`nav-badge ${c.badge.kind}`}>{c.badge.text}</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|
const [openGroups, setOpenGroups] = React.useState(new Set([]));
|
|
const [jobsBadge, setJobsBadge] = React.useState(null);
|
|
|
|
// Live jobs count (#130): poll /jobs?status=active and render the result
|
|
// as the sidebar badge on the Jobs item. Falls back to hidden on error.
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
const tick = () => {
|
|
window.ZAMPP_API.fetch('/jobs?status=active&limit=200')
|
|
.then(d => {
|
|
if (cancelled) return;
|
|
const list = Array.isArray(d) ? d : (d?.jobs || d?.items || []);
|
|
const n = Array.isArray(list) ? list.length : 0;
|
|
setJobsBadge(n > 0 ? { kind: n > 5 ? 'warning' : 'neutral', text: n > 99 ? '99+' : String(n) } : null);
|
|
})
|
|
.catch(() => setJobsBadge(null));
|
|
};
|
|
tick();
|
|
const id = setInterval(tick, 10000);
|
|
return () => { cancelled = true; clearInterval(id); };
|
|
}, []);
|
|
|
|
// (Capture live-signal badge previously lived here; it now belongs in the
|
|
// topbar status pip alongside the cluster pip. See issue #149.)
|
|
|
|
// Apply the live Jobs badge to the Operations section, and gate the Admin
|
|
// section to admins only (RBAC v2). Non-admins never see Users/Cluster/etc.
|
|
// This is UX only — the API enforces the same rules server-side.
|
|
const isAdmin = me?.role === 'admin';
|
|
const sections = React.useMemo(
|
|
() => NAV_SECTIONS
|
|
.filter(sec => sec.label !== 'Admin' || isAdmin)
|
|
.map(sec => ({
|
|
...sec,
|
|
items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
|
|
})),
|
|
[jobsBadge, isAdmin]
|
|
);
|
|
const toggleGroup = (id) => {
|
|
setOpenGroups(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
const ingestChildren = ["upload", "youtube", "recorders", "schedule", "capture", "monitors"];
|
|
if (ingestChildren.includes(active)) {
|
|
setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
|
|
}
|
|
}, [active]);
|
|
|
|
return (
|
|
<aside className="sidebar">
|
|
<div className="sidebar-header">
|
|
<div
|
|
className="brand-link"
|
|
onClick={() => onNavigate('home')}
|
|
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}
|
|
title="Home"
|
|
>
|
|
<img className="brand-logo" src="img/dragon-logo.png" alt="Dragonflight" draggable="false" />
|
|
<div className="brand-name">Dragonflight</div>
|
|
<div className="brand-sub">v1.2.0</div>
|
|
</div>
|
|
<button
|
|
className="icon-btn sidebar-toggle"
|
|
onClick={onToggle}
|
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
|
aria-expanded={!collapsed}
|
|
>
|
|
<Icon name="chevron" size={14} style={{ transform: collapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform 120ms' }} />
|
|
</button>
|
|
</div>
|
|
<div className="sidebar-scroll">
|
|
{sections.map((section, si) => (
|
|
<React.Fragment key={section.label}>
|
|
{si > 0 && <div className="nav-section-label">{section.label}</div>}
|
|
{section.items.map(item => (
|
|
<NavItem
|
|
key={item.id}
|
|
item={item}
|
|
active={active}
|
|
onSelect={onNavigate}
|
|
openGroups={openGroups}
|
|
toggleGroup={toggleGroup}
|
|
/>
|
|
))}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div className="sidebar-footer">
|
|
<div className="avatar">{me?.initials || '·'}</div>
|
|
<div className="user-meta">
|
|
<div className="user-name">{me?.name || 'Not signed in'}</div>
|
|
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false: showing the OS user running the server' : ''}>
|
|
{me?.role || '·'}{me?.synthetic ? ' · auth off' : ''}
|
|
</div>
|
|
</div>
|
|
{me?.synthetic ? null : (
|
|
<button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
|
|
onClick={async () => {
|
|
try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
|
|
window.AuthGate.bounce('user signed out');
|
|
}}>
|
|
<Icon name="power" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function assetSearchIcon(a) {
|
|
const t = (a.type || a.media_type || '').toLowerCase();
|
|
if (t.includes('audio')) return 'audio';
|
|
if (t.includes('image')) return 'image';
|
|
return 'film';
|
|
}
|
|
|
|
function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
|
|
const [q, setQ] = React.useState('');
|
|
const [open, setOpen] = React.useState(false);
|
|
const [sel, setSel] = React.useState(0);
|
|
const inputRef = React.useRef(null);
|
|
const listRef = React.useRef(null);
|
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
|
|
|
React.useEffect(() => {
|
|
const handler = (e) => {
|
|
const key = e.key && e.key.toLowerCase();
|
|
if ((e.metaKey || e.ctrlKey) && key === 'k') {
|
|
e.preventDefault();
|
|
if (inputRef.current) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
setOpen(true);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, []);
|
|
|
|
const results = React.useMemo(() => {
|
|
const term = q.trim().toLowerCase();
|
|
if (!term) return [];
|
|
const D = window.ZAMPP_DATA || {};
|
|
const out = [];
|
|
|
|
(D.ASSETS || []).forEach(a => {
|
|
const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
|
|
if (hay.includes(term)) {
|
|
const sub = [a.project, a.res, a.duration].filter(x => x && x !== '·').join(' · ');
|
|
out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
|
|
}
|
|
});
|
|
(D.PROJECTS || []).forEach(p => {
|
|
if ((p.name || '').toLowerCase().includes(term)) {
|
|
out.push({ kind: 'project', icon: 'folder', label: p.name, sub: (p.assets || 0) + ' assets', item: p, color: p.color });
|
|
}
|
|
});
|
|
(D.RECORDERS || []).forEach(r => {
|
|
const hay = ((r.name || '') + ' ' + (r.url || '') + ' ' + (r.source || '')).toLowerCase();
|
|
if (hay.includes(term)) {
|
|
out.push({ kind: 'recorder', icon: 'record', label: r.name || r.url || 'Recorder', sub: [r.status, r.source].filter(Boolean).join(' · '), item: r });
|
|
}
|
|
});
|
|
(D.JOBS || []).forEach(j => {
|
|
const hay = ((j.asset || '') + ' ' + (j.kind || '') + ' ' + (j.status || '')).toLowerCase();
|
|
if (hay.includes(term)) {
|
|
out.push({ kind: 'job', icon: 'jobs', label: j.asset || j.kind || 'Job', sub: [j.kind, j.status].filter(Boolean).join(' · '), item: j });
|
|
}
|
|
});
|
|
(D.USERS || []).forEach(u => {
|
|
const hay = ((u.name || '') + ' ' + (u.email || '') + ' ' + (u.username || '')).toLowerCase();
|
|
if (hay.includes(term)) {
|
|
out.push({ kind: 'user', icon: 'users', label: u.name, sub: u.email || u.role, item: u });
|
|
}
|
|
});
|
|
NAV_FLAT.forEach(n => {
|
|
if (n.label.toLowerCase().includes(term)) {
|
|
out.push({ kind: 'nav', icon: n.icon, label: n.label, sub: 'Go to ' + n.label, target: n.id });
|
|
}
|
|
});
|
|
|
|
return out.slice(0, 40);
|
|
}, [q, open]);
|
|
|
|
React.useEffect(() => { setSel(0); }, [q]);
|
|
|
|
React.useEffect(() => {
|
|
if (!listRef.current) return;
|
|
const el = listRef.current.querySelector('.search-result.active');
|
|
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
|
}, [sel]);
|
|
|
|
const handleSelect = (r) => {
|
|
if (!r) return;
|
|
setOpen(false);
|
|
setQ('');
|
|
if (inputRef.current) inputRef.current.blur();
|
|
if (r.kind === 'asset' && onOpenAsset) onOpenAsset(r.item);
|
|
else if (r.kind === 'project' && onOpenProject) onOpenProject(r.item);
|
|
else if (r.kind === 'recorder') onNavigate && onNavigate('recorders');
|
|
else if (r.kind === 'job') onNavigate && onNavigate('jobs');
|
|
else if (r.kind === 'user') onNavigate && onNavigate('users');
|
|
else if (r.kind === 'nav') onNavigate && onNavigate(r.target);
|
|
};
|
|
|
|
const onKeyDown = (e) => {
|
|
if (e.key === 'Escape') {
|
|
setOpen(false); setQ('');
|
|
if (inputRef.current) inputRef.current.blur();
|
|
return;
|
|
}
|
|
if (!open || results.length === 0) return;
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(s + 1, results.length - 1)); }
|
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
|
|
else if (e.key === 'Enter') { e.preventDefault(); handleSelect(results[sel]); }
|
|
};
|
|
|
|
const showDropdown = open && q.trim().length > 0;
|
|
|
|
const listboxId = 'global-search-listbox';
|
|
return (
|
|
<div className="search-wrap" role="search">
|
|
<div className={`search ${showDropdown ? 'is-open' : ''}`}>
|
|
<Icon name="search" className="search-icon" />
|
|
<input
|
|
ref={inputRef}
|
|
value={q}
|
|
onChange={e => { setQ(e.target.value); setOpen(true); }}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
|
onKeyDown={onKeyDown}
|
|
placeholder="Search assets, projects, recorders…"
|
|
type="search"
|
|
role="combobox"
|
|
aria-label="Search assets, projects, recorders, jobs, and users"
|
|
aria-expanded={showDropdown}
|
|
aria-controls={listboxId}
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={showDropdown && results[sel] ? `${listboxId}-opt-${sel}` : undefined}
|
|
/>
|
|
<span className="kbd" aria-hidden="true">{isMac ? '⌘K' : 'Ctrl K'}</span>
|
|
</div>
|
|
{showDropdown && (
|
|
<div
|
|
ref={listRef}
|
|
id={listboxId}
|
|
role="listbox"
|
|
className="search-results"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
{results.length === 0 ? (
|
|
<div className="search-empty">No results for "{q}"</div>
|
|
) : results.map((r, i) => (
|
|
<div
|
|
key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
|
|
id={`${listboxId}-opt-${i}`}
|
|
role="option"
|
|
aria-selected={i === sel}
|
|
className={`search-result ${i === sel ? 'active' : ''}`}
|
|
onClick={() => handleSelect(r)}
|
|
onMouseEnter={() => setSel(i)}
|
|
>
|
|
<span
|
|
className="search-result-icon"
|
|
style={r.color ? { color: r.color } : null}
|
|
>
|
|
<Icon name={r.icon} size={14} />
|
|
</span>
|
|
<div className="search-result-text">
|
|
<div className="search-result-label">{r.label}</div>
|
|
{r.sub && <div className="search-result-sub">{r.sub}</div>}
|
|
</div>
|
|
<span className={`search-result-kind kind-${r.kind}`}>{r.kind}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) {
|
|
// Light cluster ping - the badge in the topbar should reflect reality,
|
|
// not just look reassuring. /metrics/home returns cluster online/total.
|
|
const [clusterHealthy, setClusterHealthy] = React.useState(true);
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
const ping = () => {
|
|
window.ZAMPP_API.fetch('/metrics/home?hours=1')
|
|
.then(d => {
|
|
if (cancelled) return;
|
|
const c = d?.cards?.cluster;
|
|
setClusterHealthy(!c || c.online >= c.total);
|
|
})
|
|
.catch(() => {});
|
|
};
|
|
ping();
|
|
const t = setInterval(ping, 30_000);
|
|
return () => { cancelled = true; clearInterval(t); };
|
|
}, []);
|
|
|
|
return (
|
|
<header className="topbar">
|
|
{onToggleSidebar && (
|
|
<button
|
|
className="icon-btn topbar-menu"
|
|
onClick={onToggleSidebar}
|
|
aria-label="Toggle sidebar"
|
|
title="Toggle sidebar"
|
|
>
|
|
<Icon name="list" size={16} />
|
|
</button>
|
|
)}
|
|
<div className="crumb">
|
|
{crumbs.map((c, i) => (
|
|
<React.Fragment key={i}>
|
|
{i > 0 && <Icon name="chevron" size={12} className="sep" />}
|
|
<span
|
|
className={i === crumbs.length - 1 ? "current" : ""}
|
|
style={{ cursor: c.to && i < crumbs.length - 1 ? "pointer" : "default" }}
|
|
onClick={() => c.to && onNavigate && onNavigate(c.to)}
|
|
>
|
|
{c.label}
|
|
</span>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
<div className="spacer" />
|
|
<GlobalSearch onNavigate={onNavigate} onOpenAsset={onOpenAsset} onOpenProject={onOpenProject} />
|
|
<div className="status-pip" title={clusterHealthy ? 'All nodes online' : 'One or more nodes offline'}>
|
|
<span className="dot" style={{ background: clusterHealthy ? 'var(--success)' : 'var(--warning)' }} />
|
|
<span>{clusterHealthy ? 'cluster healthy' : 'cluster degraded'}</span>
|
|
</div>
|
|
{right}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
// General-purpose read-only form field used by recorder modal and other forms.
|
|
// Renders as a labeled select (when select=true) or text input.
|
|
function Field({ label, value, select, children }) {
|
|
return (
|
|
<div className="field">
|
|
<label className="field-label">{label}</label>
|
|
{children || (select
|
|
? (
|
|
<select className="field-input" defaultValue={value} style={{ appearance: 'auto' }}>
|
|
<option value={value}>{value}</option>
|
|
</select>
|
|
) : (
|
|
<input className="field-input" defaultValue={value} readOnly />
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
window.Sidebar = Sidebar;
|
|
window.Topbar = Topbar;
|
|
window.NAV_TREE = NAV_TREE;
|
|
window.NAV_FLAT = NAV_FLAT;
|
|
window.NAV_SECTIONS = NAV_SECTIONS;
|
|
window.Field = Field;
|
|
window.GlobalSearch = GlobalSearch;
|