// 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()}>
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 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)}
onDelete={() => deleteProject(p)}
/>
))}
) : (
Project
Assets
Storage
Updated
{filtered.map(p => (
onOpenProject(p)}>
{p.assets || 0}
—
{p.updated || '—'}
e.stopPropagation()}>
{menuFor === p.id && (
e.stopPropagation()}>
)}
))}
)}
{showNew &&
setShowNew(false)} onCreated={onCreated} />}
{renamingProject && (
setRenamingProject(null)}
onSaved={() => { setRenamingProject(null); refresh(); }}
/>
)}
);
}
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()}>
setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose(); }} />
{err &&
{err}
}
);
}
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 (
{Array.from({ length: 4 }).map((_, 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()}>
)}
);
}
window.Projects = Projects;
window.RenameProjectModal = RenameProjectModal;