Files
silo/web/src/pages/ProjectsPage.tsx
Forbes 07c4aa1c28 fix(web): align spacing values to style guide grid (#71)
- Replace 0.3rem padding/margin/gap with 0.25rem (xs)
- Replace 0.2rem margins with 0.25rem (xs)
- Replace 0.1rem padding with 0.15rem (badge spec)
- Replace 0.6rem margins/padding with 0.5rem (sm)
- Fix borderRadius 0.3rem to 0.375rem (6px per style guide)
- Preserve style-guide-specified values: 0.35rem button gap, 0.4rem cell padding, 0.45rem input padding
2026-02-13 14:37:40 -06:00

594 lines
17 KiB
TypeScript

import { useEffect, useState, useCallback, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Plus, ChevronUp, ChevronDown } from "lucide-react";
import { get, post, put, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type {
Project,
Item,
CreateProjectRequest,
UpdateProjectRequest,
} from "../api/types";
type Mode = "list" | "create" | "edit" | "delete";
interface ProjectWithCount extends Project {
itemCount: number;
}
export function ProjectsPage() {
const { user } = useAuth();
const navigate = useNavigate();
const isEditor = user?.role === "admin" || user?.role === "editor";
const [projects, setProjects] = useState<ProjectWithCount[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("list");
const [editingProject, setEditingProject] = useState<ProjectWithCount | null>(
null,
);
// Form state
const [formCode, setFormCode] = useState("");
const [formName, setFormName] = useState("");
const [formDesc, setFormDesc] = useState("");
const [formError, setFormError] = useState("");
const [formSubmitting, setFormSubmitting] = useState(false);
// Sort state
const [sortKey, setSortKey] = useState<
"code" | "name" | "itemCount" | "created_at"
>("code");
const [sortAsc, setSortAsc] = useState(true);
const loadProjects = useCallback(async () => {
try {
const list = await get<Project[]>("/api/projects");
const withCounts = await Promise.all(
list.map(async (p) => {
try {
const items = await get<Item[]>(`/api/projects/${p.code}/items`);
return { ...p, itemCount: items.length };
} catch {
return { ...p, itemCount: 0 };
}
}),
);
setProjects(withCounts);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load projects");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadProjects();
}, [loadProjects]);
const openCreate = () => {
setFormCode("");
setFormName("");
setFormDesc("");
setFormError("");
setMode("create");
};
const openEdit = (p: ProjectWithCount) => {
setEditingProject(p);
setFormCode(p.code);
setFormName(p.name ?? "");
setFormDesc(p.description ?? "");
setFormError("");
setMode("edit");
};
const openDelete = (p: ProjectWithCount) => {
setEditingProject(p);
setMode("delete");
};
const cancel = () => {
setMode("list");
setEditingProject(null);
setFormError("");
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
setFormError("");
setFormSubmitting(true);
try {
const req: CreateProjectRequest = {
code: formCode.toUpperCase(),
name: formName || undefined,
description: formDesc || undefined,
};
await post("/api/projects", req);
cancel();
await loadProjects();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to create project");
} finally {
setFormSubmitting(false);
}
};
const handleEdit = async (e: FormEvent) => {
e.preventDefault();
if (!editingProject) return;
setFormError("");
setFormSubmitting(true);
try {
const req: UpdateProjectRequest = {
name: formName,
description: formDesc,
};
await put(`/api/projects/${editingProject.code}`, req);
cancel();
await loadProjects();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to update project");
} finally {
setFormSubmitting(false);
}
};
const handleDelete = async () => {
if (!editingProject) return;
setFormSubmitting(true);
try {
await del(`/api/projects/${editingProject.code}`);
cancel();
await loadProjects();
} catch (e) {
setFormError(e instanceof Error ? e.message : "Failed to delete project");
} finally {
setFormSubmitting(false);
}
};
const handleSort = (key: typeof sortKey) => {
if (sortKey === key) {
setSortAsc(!sortAsc);
} else {
setSortKey(key);
setSortAsc(true);
}
};
const sorted = [...projects].sort((a, b) => {
let cmp = 0;
if (sortKey === "code") cmp = a.code.localeCompare(b.code);
else if (sortKey === "name")
cmp = (a.name ?? "").localeCompare(b.name ?? "");
else if (sortKey === "itemCount") cmp = a.itemCount - b.itemCount;
else if (sortKey === "created_at")
cmp = a.created_at.localeCompare(b.created_at);
return sortAsc ? cmp : -cmp;
});
const formatDate = (s: string) => {
const d = new Date(s);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const sortArrow = (key: typeof sortKey) =>
sortKey === key ? (
<span
style={{
marginLeft: 4,
display: "inline-flex",
verticalAlign: "middle",
}}
>
{sortAsc ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
) : null;
if (loading)
return <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
if (error) return <p style={{ color: "var(--ctp-red)" }}>Error: {error}</p>;
return (
<div>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Projects ({projects.length})</h2>
{isEditor && mode === "list" && (
<button
onClick={openCreate}
style={{
...btnPrimaryStyle,
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
}}
>
<Plus size={14} /> New Project
</button>
)}
</div>
{/* Create / Edit form */}
{(mode === "create" || mode === "edit") && (
<div style={formPaneStyle}>
<div
style={{
...formHeaderStyle,
backgroundColor:
mode === "create" ? "var(--ctp-green)" : "var(--ctp-blue)",
}}
>
<strong>
{mode === "create"
? "New Project"
: `Edit ${editingProject?.code}`}
</strong>
<button onClick={cancel} style={formCloseStyle}>
Cancel
</button>
</div>
<form
onSubmit={mode === "create" ? handleCreate : handleEdit}
style={{ padding: "1rem" }}
>
{formError && <div style={errorBannerStyle}>{formError}</div>}
{mode === "create" && (
<div style={fieldStyle}>
<label style={labelStyle}>
Code (2-10 characters, uppercase)
</label>
<input
className="silo-input"
type="text"
value={formCode}
onChange={(e) => setFormCode(e.target.value)}
placeholder="e.g., PROJ-A"
required
minLength={2}
maxLength={10}
style={{ ...inputStyle, textTransform: "uppercase" }}
/>
</div>
)}
<div style={fieldStyle}>
<label style={labelStyle}>Name</label>
<input
className="silo-input"
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
style={inputStyle}
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Description</label>
<input
className="silo-input"
type="text"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder="Project description"
style={inputStyle}
/>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
}}
>
<button type="button" onClick={cancel} style={btnSecondaryStyle}>
Cancel
</button>
<button
type="submit"
disabled={formSubmitting}
style={btnPrimaryStyle}
>
{formSubmitting
? "Saving..."
: mode === "create"
? "Create Project"
: "Save Changes"}
</button>
</div>
</form>
</div>
)}
{/* Delete confirmation */}
{mode === "delete" && editingProject && (
<div style={formPaneStyle}>
<div
style={{ ...formHeaderStyle, backgroundColor: "var(--ctp-red)" }}
>
<strong>Delete Project</strong>
<button onClick={cancel} style={formCloseStyle}>
Cancel
</button>
</div>
<div style={{ padding: "1.5rem", textAlign: "center" }}>
{formError && <div style={errorBannerStyle}>{formError}</div>}
<p>
Are you sure you want to permanently delete project{" "}
<strong style={{ color: "var(--ctp-peach)" }}>
{editingProject.code}
</strong>
?
</p>
<p
style={{
color: "var(--ctp-red)",
marginTop: "0.5rem",
fontSize: "var(--font-body)",
}}
>
This action cannot be undone.
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "center",
marginTop: "1.5rem",
}}
>
<button onClick={cancel} style={btnSecondaryStyle}>
Cancel
</button>
<button
onClick={handleDelete}
disabled={formSubmitting}
style={btnDangerStyle}
>
{formSubmitting ? "Deleting..." : "Delete Permanently"}
</button>
</div>
</div>
</div>
)}
{/* Table */}
<div style={tableContainerStyle}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle} onClick={() => handleSort("code")}>
Code{sortArrow("code")}
</th>
<th style={thStyle} onClick={() => handleSort("name")}>
Name{sortArrow("name")}
</th>
<th style={thStyle}>Description</th>
<th style={thStyle} onClick={() => handleSort("itemCount")}>
Items{sortArrow("itemCount")}
</th>
<th style={thStyle} onClick={() => handleSort("created_at")}>
Created{sortArrow("created_at")}
</th>
{isEditor && <th style={thStyle}>Actions</th>}
</tr>
</thead>
<tbody>
{sorted.length === 0 ? (
<tr>
<td
colSpan={isEditor ? 6 : 5}
style={{
...tdStyle,
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
}}
>
No projects found. Create your first project to start
organizing items.
</td>
</tr>
) : (
sorted.map((p, i) => (
<tr
key={p.id}
style={{
backgroundColor:
i % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
}}
>
<td style={tdStyle}>
<span
onClick={() =>
navigate(`/?project=${encodeURIComponent(p.code)}`)
}
style={{
color: "var(--ctp-peach)",
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 500,
cursor: "pointer",
}}
>
{p.code}
</span>
</td>
<td style={tdStyle}>{p.name || "-"}</td>
<td style={tdStyle}>{p.description || "-"}</td>
<td style={tdStyle}>{p.itemCount}</td>
<td style={tdStyle}>{formatDate(p.created_at)}</td>
{isEditor && (
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={() => openEdit(p)}
style={btnSmallStyle}
>
Edit
</button>
<button
onClick={() => openDelete(p)}
style={{
...btnSmallStyle,
backgroundColor: "var(--ctp-surface2)",
}}
>
Delete
</button>
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
// Styles
const btnPrimaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
};
const btnSecondaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
};
const btnDangerStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-red)",
color: "var(--ctp-crust)",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
};
const btnSmallStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
borderRadius: "0.375rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
};
const formPaneStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.5rem",
marginBottom: "1rem",
overflow: "hidden",
};
const formHeaderStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.5rem 1rem",
color: "var(--ctp-crust)",
fontSize: "var(--font-body)",
};
const formCloseStyle: React.CSSProperties = {
background: "none",
border: "none",
color: "inherit",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
borderRadius: "0.375rem",
};
const errorBannerStyle: React.CSSProperties = {
color: "var(--ctp-red)",
background: "rgba(243, 139, 168, 0.1)",
border: "1px solid rgba(243, 139, 168, 0.2)",
padding: "0.5rem 0.75rem",
borderRadius: "0.4rem",
marginBottom: "0.75rem",
fontSize: "var(--font-body)",
};
const fieldStyle: React.CSSProperties = {
marginBottom: "0.75rem",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "0.35rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "var(--font-body)",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.5rem 0.75rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.4rem",
color: "var(--ctp-text)",
fontSize: "var(--font-body)",
boxSizing: "border-box",
};
const tableContainerStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.75rem",
padding: "0.5rem",
overflowX: "auto",
};
const thStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-overlay1)",
fontWeight: 600,
fontSize: "var(--font-table)",
textTransform: "uppercase",
letterSpacing: "0.05em",
cursor: "pointer",
userSelect: "none",
};
const tdStyle: React.CSSProperties = {
padding: "0.35rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "var(--font-body)",
};