// 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 (
e.stopPropagation()}>
New project
setName(e.target.value)} placeholder="e.g. Sunday Night Game" autoFocus onKeyDown={e => e.key === 'Enter' && !saving && create()} />
{err &&
{err}
}
); } 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 [accessProject, setAccessProject] = React.useState(null); const isAdmin = window.ZAMPP_DATA?.ME?.role === 'admin'; const manageAccess = (p) => { setMenuFor(null); setAccessProject(p); }; 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 (

Projects

{filtered.length} projects
setSearch(e.target.value)} placeholder="Search projects…" />
{filtered.length === 0 ? (
{search ? 'No matching projects.' : 'No projects yet.'} {!search && (
)}
) : view === 'grid' ? (
{filtered.map(p => ( onOpenProject(p)} onRename={() => renameProject(p)} onManageAccess={() => manageAccess(p)} onDelete={() => deleteProject(p)} /> ))}
) : (
Project
Assets
Storage
Updated
{filtered.map(p => (
onOpenProject(p)}>
{p.name}
{p.assets || 0}
·
{p.updated || '·'}
e.stopPropagation()}> {menuFor === p.id && (
e.stopPropagation()}> {isAdmin && }
)}
))}
)}
{showNew && setShowNew(false)} onCreated={onCreated} />} {renamingProject && ( setRenamingProject(null)} onSaved={() => { setRenamingProject(null); refresh(); }} /> )} {accessProject && ( setAccessProject(null)} /> )}
); } // Admin-only: grant/revoke per-project access to users and groups. // Backed by GET/POST/DELETE /api/v1/projects/:id/access. function ProjectAccessModal({ project, onClose }) { const [grants, setGrants] = React.useState([]); const [users, setUsers] = React.useState([]); const [groups, setGroups] = React.useState([]); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); // Add-grant form state. const [subjType, setSubjType] = React.useState('user'); const [subjId, setSubjId] = React.useState(''); const [level, setLevel] = React.useState('view'); const loadGrants = React.useCallback(() => { return window.ZAMPP_API.fetch('/projects/' + project.id + '/access') .then(list => setGrants(list || [])) .catch(e => setErr(e.message)); }, [project.id]); React.useEffect(() => { setLoading(true); Promise.all([ loadGrants(), window.ZAMPP_API.fetch('/users').then(setUsers).catch(() => setUsers([])), window.ZAMPP_API.fetch('/groups').then(setGroups).catch(() => setGroups([])), ]).finally(() => setLoading(false)); }, [loadGrants]); const addGrant = () => { if (!subjId) return; setErr(null); window.ZAMPP_API.fetch('/projects/' + project.id + '/access', { method: 'POST', body: JSON.stringify({ subject_type: subjType, subject_id: subjId, level }), }) .then(() => { setSubjId(''); return loadGrants(); }) .catch(e => setErr(e.message || 'Failed to add grant')); }; const revoke = (g) => { window.ZAMPP_API.fetch('/projects/' + project.id + '/access/' + g.subject_type + '/' + g.subject_id, { method: 'DELETE' }) .then(loadGrants) .catch(e => setErr(e.message || 'Failed to revoke')); }; // Candidates for the picker — exclude subjects that already have a grant. const grantedIds = new Set(grants.filter(g => g.subject_type === subjType).map(g => g.subject_id)); const candidates = (subjType === 'user' ? users : groups).filter(c => !grantedIds.has(c.id)); return (
e.stopPropagation()}>
Manage access · {project.name}
Admins always have full access. Grant specific users or groups view (read-only) or edit (read-write) access to this project.
{/* Add-grant row */}
{err &&
{err}
} {/* Existing grants */}
{loading &&
Loading…
} {!loading && grants.length === 0 && (
No grants yet — only admins can see this project.
)} {!loading && grants.map(g => (
{g.subject_name || '(deleted)'}
{g.username &&
@{g.username}
}
{g.level}
))}
); } 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 (
e.stopPropagation()}>
Rename project
setName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />
{err &&
{err}
}
); } function ProjectCard({ project, assets, onOpen, onRename, onManageAccess, onDelete, canManageAccess }) { 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 (
{Array.from({ length: 4 }).map((_, i) => (
{thumbAssets[i] ? : }
))}
{project.name}
{ofProject.length} asset{ofProject.length === 1 ? '' : 's'} · updated {project.updated || '·'}
{ofProject.length > 0 ? (
{ready > 0 &&
} {inFlight > 0 &&
} {errored > 0 &&
}
) : (
)}
{ctx && (
e.stopPropagation()}> {canManageAccess && }
)}
); } window.Projects = Projects; window.RenameProjectModal = RenameProjectModal; window.ProjectAccessModal = ProjectAccessModal;