Standardize all spacing to multiples of 4px (0.25rem): - 0.15rem (2.4px) → 0.25rem (4px) - 0.35rem (5.6px) → 0.25rem (4px) - 0.375rem (6px) → 0.25rem (4px) for borderRadius - 0.4rem (6.4px) → 0.5rem (8px) - 0.6rem (9.6px) → 0.5rem (8px) Updated theme.css density variables, silo-base.css focus ring, and all TSX component inline styles. Closes #71
594 lines
17 KiB
TypeScript
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.25rem",
|
|
}}
|
|
>
|
|
<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.25rem",
|
|
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.25rem",
|
|
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.25rem",
|
|
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.25rem",
|
|
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.25rem",
|
|
};
|
|
|
|
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.5rem",
|
|
marginBottom: "0.75rem",
|
|
fontSize: "var(--font-body)",
|
|
};
|
|
|
|
const fieldStyle: React.CSSProperties = {
|
|
marginBottom: "0.75rem",
|
|
};
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
display: "block",
|
|
marginBottom: "0.25rem",
|
|
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.5rem",
|
|
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.25rem 0.75rem",
|
|
borderBottom: "1px solid var(--ctp-surface1)",
|
|
fontSize: "var(--font-body)",
|
|
};
|