diff --git a/apps/web/src/app/(authenticated)/projects/page.tsx b/apps/web/src/app/(authenticated)/projects/page.tsx new file mode 100644 index 0000000..6d97d80 --- /dev/null +++ b/apps/web/src/app/(authenticated)/projects/page.tsx @@ -0,0 +1,809 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { ReactElement, SyntheticEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Plus, Trash2 } from "lucide-react"; + +import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { fetchProjects, createProject, deleteProject, ProjectStatus } from "@/lib/api/projects"; +import type { Project, CreateProjectDto } from "@/lib/api/projects"; +import { useWorkspaceId } from "@/lib/hooks"; + +/* --------------------------------------------------------------------------- + Status badge helpers + --------------------------------------------------------------------------- */ + +interface StatusStyle { + label: string; + bg: string; + color: string; +} + +function getStatusStyle(status: ProjectStatus): StatusStyle { + switch (status) { + case ProjectStatus.PLANNING: + return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" }; + case ProjectStatus.ACTIVE: + return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" }; + case ProjectStatus.PAUSED: + return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case ProjectStatus.COMPLETED: + return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" }; + case ProjectStatus.ARCHIVED: + return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: String(status), bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return iso; + } +} + +/* --------------------------------------------------------------------------- + ProjectCard + --------------------------------------------------------------------------- */ + +interface ProjectCardProps { + project: Project; + onDelete: (id: string) => void; + onClick: (id: string) => void; +} + +function ProjectCard({ project, onDelete, onClick }: ProjectCardProps): ReactElement { + const [hovered, setHovered] = useState(false); + const status = getStatusStyle(project.status); + + return ( +
{ + onClick(project.id); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(project.id); + } + }} + onMouseEnter={() => { + setHovered(true); + }} + onMouseLeave={() => { + setHovered(false); + }} + style={{ + background: "var(--surface)", + border: `1px solid ${hovered ? "var(--primary)" : "var(--border)"}`, + borderRadius: "var(--r-lg)", + padding: 20, + cursor: "pointer", + transition: "border-color 0.2s var(--ease)", + display: "flex", + flexDirection: "column", + gap: 12, + position: "relative", + }} + > + {/* Header row: name + delete button */} +
+
+

+ {project.name} +

+
+ + {/* Delete button */} + +
+ + {/* Description */} + {project.description ? ( +

+ {project.description} +

+ ) : ( +

+ No description +

+ )} + + {/* Footer: status + timestamps */} +
+ {/* Status badge */} + + {status.label} + + + {/* Timestamps */} + + {formatTimestamp(project.createdAt)} + +
+
+ ); +} + +/* --------------------------------------------------------------------------- + Create Project Dialog + --------------------------------------------------------------------------- */ + +interface CreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: CreateProjectDto) => Promise; + isSubmitting: boolean; +} + +function CreateProjectDialog({ + open, + onOpenChange, + onSubmit, + isSubmitting, +}: CreateDialogProps): ReactElement { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [formError, setFormError] = useState(null); + + function resetForm(): void { + setName(""); + setDescription(""); + setFormError(null); + } + + async function handleSubmit(e: SyntheticEvent): Promise { + e.preventDefault(); + setFormError(null); + + const trimmedName = name.trim(); + if (!trimmedName) { + setFormError("Project name is required."); + return; + } + + try { + const payload: CreateProjectDto = { name: trimmedName }; + const trimmedDesc = description.trim(); + if (trimmedDesc) { + payload.description = trimmedDesc; + } + await onSubmit(payload); + resetForm(); + } catch (err: unknown) { + setFormError(err instanceof Error ? err.message : "Failed to create project."); + } + } + + return ( + { + if (!isOpen) resetForm(); + onOpenChange(isOpen); + }} + > + +
+ + + New Project + + + + Give your project a name and optional description. + + + + +
{ + void handleSubmit(e); + }} + style={{ marginTop: 16 }} + > + {/* Name */} +
+ + { + setName(e.target.value); + }} + placeholder="e.g. Website Redesign" + maxLength={255} + autoFocus + style={{ + width: "100%", + padding: "8px 12px", + background: "var(--bg)", + border: "1px solid var(--border)", + borderRadius: "var(--r)", + color: "var(--text)", + fontSize: "0.9rem", + outline: "none", + boxSizing: "border-box", + }} + /> +
+ + {/* Description */} +
+ +