From 47ad01d0b2affeead630169e1f679b88f790f5ad Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 23 May 2026 04:08:59 +0000 Subject: [PATCH] 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. --- services/mam-api/src/routes/bins.js | 20 +++-- services/web-ui/public/data.jsx | 1 + services/web-ui/public/screens-jobs.jsx | 26 ++++++- services/web-ui/public/screens-library.jsx | 22 +++--- services/web-ui/public/screens-projects.jsx | 85 ++++++++++++++++++--- 5 files changed, 128 insertions(+), 26 deletions(-) diff --git a/services/mam-api/src/routes/bins.js b/services/mam-api/src/routes/bins.js index 94c6063..8344bce 100644 --- a/services/mam-api/src/routes/bins.js +++ b/services/mam-api/src/routes/bins.js @@ -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); diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index a083d5d..6b92630 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -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 { diff --git a/services/web-ui/public/screens-jobs.jsx b/services/web-ui/public/screens-jobs.jsx index e643eec..44c8aa0 100644 --- a/services/web-ui/public/screens-jobs.jsx +++ b/services/web-ui/public/screens-jobs.jsx @@ -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 }) {

Jobs

Proxy generation, transcoding, and processing queue
+ {counts.failed > 0 && ( + + )} @@ -142,7 +158,15 @@ function JobRow({ job, onRetry, onDelete }) { )} {job.status === 'done' && Complete} {job.status === 'queued' && Waiting…} - {job.status === 'failed' && {job.error || 'Failed'}} + {job.status === 'failed' && ( + + + + {(job.error || 'Failed').slice(0, 120)} + + + )}
{job.eta}
{job.priority}
diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index 3bd01b8..8b7a740 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -240,14 +240,18 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen }) { {(bins && bins.length > 0) ? ( <>
Move to bin
- {bins.slice(0, 10).map(function(b) { - const isCurrent = asset.bin_id === b.id; - return ( - - ); - })} + {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 ( + + ); + })} {asset.bin_id && ( diff --git a/services/web-ui/public/screens-projects.jsx b/services/web-ui/public/screens-projects.jsx index 02c4ef2..34227df 100644 --- a/services/web-ui/public/screens-projects.jsx +++ b/services/web-ui/public/screens-projects.jsx @@ -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 }) {
{p.assets || 0}
{p.updated || '—'}
- +
e.stopPropagation()}> + + {menuFor === p.id && ( +
e.stopPropagation()}> + + + +
+ )} +
))} @@ -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 (
@@ -131,14 +189,19 @@ function ProjectCard({ project, assets, onOpen }) { {project.name}
- {project.assets || 0} assets + {ofProject.length} asset{ofProject.length === 1 ? '' : 's'} · updated {project.updated || '—'}
-
-
-
-
+ {ofProject.length > 0 ? ( +
+ {ready > 0 &&
} + {inFlight > 0 &&
} + {errored > 0 &&
} +
+ ) : ( +
+ )}
);