187 lines
6.1 KiB
JavaScript
187 lines
6.1 KiB
JavaScript
// shell.jsx - app shell: sidebar nav, topbar, route container
|
|
|
|
const NAV_TREE = [
|
|
{ id: "home", label: "Home", icon: "home" },
|
|
{ id: "library", label: "Library", icon: "library" },
|
|
{ id: "projects", label: "Projects", icon: "folder" },
|
|
{
|
|
id: "ingest", label: "Ingest", icon: "upload", group: true,
|
|
children: [
|
|
{ id: "upload", label: "Upload", icon: "upload" },
|
|
{ id: "recorders", label: "Recorders", icon: "record", badge: { kind: "live", text: "4" } },
|
|
{ id: "capture", label: "Capture", icon: "capture" },
|
|
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
|
],
|
|
},
|
|
{ id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } },
|
|
{ id: "editor", label: "Editor", icon: "editor", badge: { kind: "dev", text: "DEV" } },
|
|
];
|
|
|
|
const ADMIN_TREE = [
|
|
{ id: "users", label: "Users", icon: "users" },
|
|
{ id: "tokens", label: "Tokens", icon: "token" },
|
|
{ id: "containers", label: "Containers", icon: "container" },
|
|
{ id: "cluster", label: "Cluster", icon: "cluster" },
|
|
{ id: "settings", label: "Settings", icon: "settings" },
|
|
];
|
|
|
|
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" : ""}`}
|
|
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 }) {
|
|
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
|
|
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", "recorders", "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-mark">D</div>
|
|
<div>
|
|
<div className="brand-name">Dragonflight</div>
|
|
</div>
|
|
<div className="brand-sub">v1.2.0</div>
|
|
</div>
|
|
<div className="sidebar-scroll">
|
|
{NAV_TREE.map(item => (
|
|
<NavItem
|
|
key={item.id}
|
|
item={item}
|
|
active={active}
|
|
onSelect={onNavigate}
|
|
openGroups={openGroups}
|
|
toggleGroup={toggleGroup}
|
|
/>
|
|
))}
|
|
<div className="nav-section-label">Admin</div>
|
|
{ADMIN_TREE.map(item => (
|
|
<NavItem
|
|
key={item.id}
|
|
item={item}
|
|
active={active}
|
|
onSelect={onNavigate}
|
|
openGroups={openGroups}
|
|
toggleGroup={toggleGroup}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="sidebar-footer">
|
|
<div className="avatar">ZG</div>
|
|
<div className="user-meta">
|
|
<div className="user-name">Zach Gaetano</div>
|
|
<div className="user-role">admin · broadcast</div>
|
|
</div>
|
|
<button className="icon-btn" data-tip="Sign out"><Icon name="power" /></button>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function Topbar({ crumbs, onNavigate, right }) {
|
|
return (
|
|
<header className="topbar">
|
|
<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" />
|
|
<div className="search">
|
|
<Icon name="search" className="search-icon" />
|
|
<input placeholder="Search assets, projects, comments…" />
|
|
<span className="kbd">⌘K</span>
|
|
</div>
|
|
<div className="status-pip">
|
|
<span className="dot" />
|
|
<span>cluster healthy</span>
|
|
</div>
|
|
<button className="icon-btn" data-tip="Notifications">
|
|
<Icon name="bell" />
|
|
</button>
|
|
{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.Field = Field;
|