dragonflight/services/web-ui/public/screens-projects.jsx
ZGaetano 342b56af35 ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)
Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.

## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)

## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
  styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)

## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
  width on progress bar). Live DOM was 487 inline-styled elements due
  to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
  job-row-actions, job-row-status-* utility classes in styles-screens.css

## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
  Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
  not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor

## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
  beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)

## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing

## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
  live recorders, 'Last 24 hours' tiles for newly created assets, plus
  an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen

## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title

## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
  badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
2026-05-28 23:50:07 +00:00

286 lines
13 KiB
JavaScript

// screens-projects.jsx
function NewProjectModal({ onClose, onCreated }) {
const [name, setName] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const create = () => {
if (!name.trim()) { setErr('Name is required'); return; }
setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/projects', { method: 'POST', body: JSON.stringify({ name: name.trim() }) })
.then(p => { onCreated(p); onClose(); })
.catch(e => { setSaving(false); setErr(e.message || 'Failed to create project'); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>New project</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Project name</label>
<input className="field-input" value={name} onChange={e => setName(e.target.value)}
placeholder="e.g. Sunday Night Game" autoFocus
onKeyDown={e => e.key === 'Enter' && !saving && create()} />
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost" onClick={onClose}>Cancel</button>
<span style={{ flex: 1 }} />
<button className="btn primary" onClick={create} disabled={saving}>
{saving ? 'Creating…' : 'Create project'}
</button>
</div>
</div>
</div>
);
}
function Projects({ onOpenProject, navigate }) {
const [projects, setProjects] = React.useState(window.ZAMPP_DATA?.PROJECTS || []);
const ASSETS = window.ZAMPP_DATA?.ASSETS || [];
const [search, setSearch] = React.useState('');
const [view, setView] = React.useState('grid');
const [showNew, setShowNew] = React.useState(false);
const [menuFor, setMenuFor] = React.useState(null);
const [renamingProject, setRenamingProject] = React.useState(null);
const refresh = React.useCallback(() => {
window.ZAMPP_API.fetch('/projects')
.then(list => {
const updated = (list || []).map((p, i) => ({
...p,
color: (window.ZAMPP_DATA.PROJECTS.find(x => x.id === p.id) || {}).color
|| (window.PROJECT_COLORS ? window.PROJECT_COLORS[p.id.charCodeAt(p.id.length - 1) % window.PROJECT_COLORS.length] : null)
|| 'var(--accent)',
assets: (ASSETS || []).filter(a => a.project_id === p.id).length,
updated: window.ZAMPP_API.fmtRelative(p.updated_at),
}));
window.ZAMPP_DATA.PROJECTS = updated;
setProjects(updated);
})
.catch(() => {});
}, [ASSETS]);
const onCreated = (p) => { refresh(); };
const renameProject = (p) => { setMenuFor(null); setRenamingProject(p); };
const deleteProject = (p) => {
setMenuFor(null);
if (!confirm('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));
};
React.useEffect(() => {
if (!menuFor) return;
const close = () => setMenuFor(null);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [menuFor]);
let filtered = projects;
if (search) filtered = filtered.filter(p => p.name.toLowerCase().includes(search.toLowerCase()));
return (
<div className="page">
<div className="page-header">
<h1>Projects</h1>
<span className="subtitle">{filtered.length} projects</span>
<div className="spacer" />
<div className="search" style={{ width: 240 }}>
<Icon name="search" className="search-icon" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" />
</div>
<div className="tab-group">
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
</div>
<button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>
</div>
<div className="page-body">
{filtered.length === 0 ? (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
{search ? 'No matching projects.' : 'No projects yet.'}
{!search && (
<div style={{ marginTop: 12 }}>
<button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>
</div>
)}
</div>
) : view === 'grid' ? (
<div className="projects-grid">
{filtered.map(p => (
<ProjectCard
key={p.id}
project={p}
assets={ASSETS}
onOpen={() => onOpenProject(p)}
onRename={() => renameProject(p)}
onDelete={() => deleteProject(p)}
/>
))}
</div>
) : (
<div className="panel">
<div className="list-row head" style={{ padding: '12px 16px', gridTemplateColumns: '1fr 100px 120px 120px 80px' }}>
<div>Project</div><div>Assets</div><div>Storage</div><div>Updated</div><div></div>
</div>
{filtered.map(p => (
<div key={p.id} className="list-row" style={{ padding: '12px 16px', gridTemplateColumns: '1fr 100px 120px 120px 80px', borderBottom: '1px solid var(--border)', cursor: 'pointer' }} onClick={() => onOpenProject(p)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 8, height: 32, borderRadius: 2, background: p.color || 'var(--accent)' }} />
<div>{p.name}</div>
</div>
<div className="col-sub">{p.assets || 0}</div>
<div className="col-sub">·</div>
<div className="col-sub">{p.updated || '·'}</div>
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
<button className="icon-btn" aria-label="Project actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
{menuFor === p.id && (
<div className="row-menu" onClick={e => e.stopPropagation()}>
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => renameProject(p)}><Icon name="edit" size={11} />Rename</button>
<button className="danger" onClick={() => deleteProject(p)}><Icon name="trash" size={11} />Delete</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{showNew && <NewProjectModal onClose={() => setShowNew(false)} onCreated={onCreated} />}
{renamingProject && (
<RenameProjectModal
project={renamingProject}
onClose={() => setRenamingProject(null)}
onSaved={() => { setRenamingProject(null); refresh(); }}
/>
)}
</div>
);
}
function RenameProjectModal({ project, onClose, onSaved }) {
const [name, setName] = React.useState(project.name || '');
const [saving, setSaving] = React.useState(false);
const [err, setErr] = React.useState(null);
const submit = () => {
const trimmed = name.trim();
if (!trimmed || trimmed === project.name) { onClose(); return; }
setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/projects/' + project.id, { method: 'PATCH', body: JSON.stringify({ name: trimmed }) })
.then(onSaved)
.catch(e => { setSaving(false); setErr(e.message); });
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Rename project</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="field">
<label className="field-label">Project name</label>
<input className="field-input" autoFocus value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
</div>
<div className="modal-foot">
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
<button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Rename'}</button>
</div>
</div>
</div>
);
}
function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
const ofProject = assets.filter(a => a.project_id === project.id);
const thumbAssets = ofProject.slice(0, 4);
// Real status distribution - ready vs processing/live vs error.
const total = ofProject.length || 1;
const ready = ofProject.filter(a => a.status === 'ready').length;
const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
const errored = ofProject.filter(a => a.status === 'error').length;
const readyPct = (ready / total) * 100;
const inFlightPct = (inFlight / total) * 100;
const errPct = (errored / total) * 100;
// #50: context menu state for grid card
const [ctx, setCtx] = React.useState(null);
React.useEffect(() => {
if (!ctx) return;
const close = () => setCtx(null);
window.addEventListener('click', close);
window.addEventListener('contextmenu', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('contextmenu', close);
window.removeEventListener('scroll', close, true);
};
}, [ctx]);
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY });
};
return (
<div className="project-card" onClick={onOpen} onContextMenu={handleContextMenu}>
<div className="project-thumb-grid">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="project-thumb-cell">
{thumbAssets[i]
? <AssetThumb asset={thumbAssets[i]} />
: <FauxFrame />}
</div>
))}
</div>
<div className="project-card-body">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 10, height: 10, borderRadius: 2, background: project.color || 'var(--accent)' }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>{project.name}</span>
</div>
<div className="project-meta">
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
<span>·</span>
<span>updated {project.updated || '·'}</span>
</div>
{ofProject.length > 0 ? (
<div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>
{ready > 0 && <div className="project-segment" style={{ width: readyPct + '%', background: 'var(--success)' }} />}
{inFlight > 0 && <div className="project-segment" style={{ width: inFlightPct + '%', background: 'var(--accent)' }} />}
{errored > 0 && <div className="project-segment" style={{ width: errPct + '%', background: 'var(--danger)' }} />}
</div>
) : (
<div className="project-bar"><div className="project-segment" style={{ width: '100%', background: 'var(--bg-3)' }} /></div>
)}
</div>
{ctx && (
<div className="row-menu" style={{ position: 'fixed', top: ctx.y, left: ctx.x, zIndex: 9999 }} onClick={e => e.stopPropagation()}>
<button onClick={() => { setCtx(null); onOpen(); }}><Icon name="library" size={11} />Open</button>
<button onClick={() => { setCtx(null); onRename && onRename(); }}><Icon name="edit" size={11} />Rename</button>
<button className="danger" onClick={() => { setCtx(null); onDelete && onDelete(); }}><Icon name="trash" size={11} />Delete</button>
</div>
)}
</div>
);
}
window.Projects = Projects;
window.RenameProjectModal = RenameProjectModal;