polish(projects,jobs,bins): row menus, real status bars, bulk retry

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.
This commit is contained in:
claude 2026-05-23 04:08:59 +00:00 committed by Zachary Gaetano
parent f474a77bcb
commit 47ad01d0b2
5 changed files with 128 additions and 26 deletions

View file

@ -7,18 +7,28 @@ const router = express.Router();
router.use(requireAuth);
// GET / - List bins for a project_id
// GET / - List bins. Filter by project_id when supplied; otherwise return
// every bin across every project so the Library / asset-context-menu can
// present a global "move to bin" picker.
router.get('/', async (req, res, next) => {
try {
const { project_id } = req.query;
if (!project_id) {
return res.status(400).json({ error: 'project_id is required' });
const params = [];
let where = '';
if (project_id) {
where = 'WHERE b.project_id = $1';
params.push(project_id);
}
const result = await pool.query(
`SELECT * FROM bins WHERE project_id = $1 ORDER BY created_at DESC`,
[project_id]
`SELECT b.*, p.name AS project_name,
(SELECT COUNT(*)::int FROM assets a WHERE a.bin_id = b.id) AS asset_count
FROM bins b
LEFT JOIN projects p ON p.id = b.project_id
${where}
ORDER BY b.created_at DESC`,
params
);
res.json(result.rows);

View file

@ -51,6 +51,7 @@ function fmtRelative(iso) {
}
const PROJECT_COLORS = ['#5B7CFA', '#2DD4A8', '#FF5B5B', '#F5A623', '#B57CFA', '#6B7280'];
window.PROJECT_COLORS = PROJECT_COLORS;
function normalizeAsset(a, projectMap) {
return {

View file

@ -51,6 +51,17 @@ function Jobs({ navigate }) {
.catch(e => alert('Delete failed: ' + e.message));
}, []);
// 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.
const handleRetryAll = React.useCallback(() => {
const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return;
if (!window.confirm(`Re-queue all ${failedJobs.length} failed jobs?`)) return;
Promise.allSettled(
failedJobs.map(j => window.ZAMPP_API.fetch('/jobs/' + j.id + '/retry', { method: 'POST' }))
).then(refresh);
}, [jobs, refresh]);
const counts = {
all: jobs.length,
running: jobs.filter(j => j.status === 'running').length,
@ -66,6 +77,11 @@ function Jobs({ navigate }) {
<h1>Jobs</h1>
<span className="subtitle">Proxy generation, transcoding, and processing queue</span>
<div className="spacer" />
{counts.failed > 0 && (
<button className="btn ghost sm" onClick={handleRetryAll} title={`Retry all ${counts.failed} failed jobs`}>
<Icon name="refresh" />Retry all failed
</button>
)}
<button className="btn ghost sm" onClick={refresh}>
<Icon name="refresh" />Refresh
</button>
@ -142,7 +158,15 @@ function JobRow({ job, onRetry, onDelete }) {
)}
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>}
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting</span>}
{job.status === 'failed' && <span style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4 }}><Icon name="alert" size={12} />{job.error || 'Failed'}</span>}
{job.status === 'failed' && (
<span title={job.error || 'Failed'}
style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
<Icon name="alert" size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{(job.error || 'Failed').slice(0, 120)}
</span>
</span>
)}
</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-3)' }}>{job.eta}</div>
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>

View file

@ -240,14 +240,18 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
{(bins && bins.length > 0) ? (
<>
<div className="ctx-section-label">Move to bin</div>
{bins.slice(0, 10).map(function(b) {
const isCurrent = asset.bin_id === b.id;
return (
<button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}>
<Icon name="folder" size={11} />{b.name}{isCurrent && <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--text-3)' }}>current</span>}
</button>
);
})}
{bins
.filter(function(b) { return !asset.project_id || b.project_id === asset.project_id; })
.slice(0, 10)
.map(function(b) {
const isCurrent = asset.bin_id === b.id;
return (
<button key={b.id} onClick={function() { moveToBin(b.id); }} disabled={isCurrent}
title={b.project_name ? 'in ' + b.project_name : ''}>
<Icon name="folder" size={11} />{b.name}{isCurrent && <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--text-3)' }}>current</span>}
</button>
);
})}
{asset.bin_id && (
<button onClick={function() { moveToBin(null); }}>
<Icon name="x" size={11} />Remove from bin
@ -255,7 +259,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) {
)}
</>
) : (
<div className="ctx-empty">No bins create one in a project</div>
<div className="ctx-empty">No bins create one inside a project</div>
)}
<div className="ctx-divider" />
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>

View file

@ -47,13 +47,51 @@ function Projects({ onOpenProject, navigate }) {
const [search, setSearch] = React.useState('');
const [view, setView] = React.useState('grid');
const [showNew, setShowNew] = React.useState(false);
const [menuFor, setMenuFor] = React.useState(null);
const onCreated = (p) => {
const updated = [p, ...window.ZAMPP_DATA.PROJECTS];
window.ZAMPP_DATA.PROJECTS = updated;
setProjects(updated);
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()));
@ -101,7 +139,16 @@ function Projects({ onOpenProject, navigate }) {
<div className="col-sub">{p.assets || 0}</div>
<div className="col-sub"></div>
<div className="col-sub">{p.updated || '—'}</div>
<button className="icon-btn" onClick={e => e.stopPropagation()}><Icon name="more" /></button>
<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>
@ -113,7 +160,18 @@ function Projects({ onOpenProject, navigate }) {
}
function ProjectCard({ project, assets, onOpen }) {
const thumbAssets = assets.filter(a => a.project_id === project.id).slice(0, 4);
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">
@ -131,14 +189,19 @@ function ProjectCard({ project, assets, onOpen }) {
<span style={{ fontWeight: 600, fontSize: 14 }}>{project.name}</span>
</div>
<div className="project-meta">
<span>{project.assets || 0} assets</span>
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
<span>·</span>
<span>updated {project.updated || '—'}</span>
</div>
<div className="project-bar">
<div className="project-segment" style={{ width: '70%', background: project.color || 'var(--accent)' }} />
<div className="project-segment" style={{ width: '20%', background: 'var(--bg-4)' }} />
</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>
);