Projects: - Per-row 3-dot menu in list view: Open / Rename / Delete (PATCH + DELETE) - ProjectCard's bottom bar now shows real ready/in-flight/error counts for the project's assets instead of fake 70/20 segments - After mutations, project list refreshes from /projects + recomputes asset counts client-side Bins: - GET /api/v1/bins now returns every bin across every project when no project_id is supplied; result rows include project_name + asset_count - Asset right-click 'Move to bin' filters to bins in the same project as the asset and surfaces project_name as a tooltip Jobs: - 'Retry all failed' button in the header appears when there are failed jobs and POSTs /retry for each one in parallel - Failed-row error message now clips with title= tooltip so 3KB ffmpeg stderr doesn't blow out the row layout window.PROJECT_COLORS exposed for cross-screen access.
210 lines
9.6 KiB
JavaScript
210 lines
9.6 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" 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;
|
|
const [search, setSearch] = React.useState('');
|
|
const [view, setView] = React.useState('grid');
|
|
const [showNew, setShowNew] = React.useState(false);
|
|
const [menuFor, setMenuFor] = 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?.[i % (window.PROJECT_COLORS?.length || 1)]
|
|
|| '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);
|
|
const next = prompt('Rename project', p.name);
|
|
if (!next || !next.trim() || next.trim() === p.name) return;
|
|
window.ZAMPP_API.fetch('/projects/' + p.id, { method: 'PATCH', body: JSON.stringify({ name: next.trim() }) })
|
|
.then(refresh)
|
|
.catch(e => alert('Rename failed: ' + e.message));
|
|
};
|
|
|
|
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')}><Icon name="grid" size={12} /></button>
|
|
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><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)} />)}
|
|
</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" 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} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectCard({ project, assets, onOpen }) {
|
|
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;
|
|
|
|
return (
|
|
<div className="project-card" onClick={onOpen}>
|
|
<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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
window.Projects = Projects;
|