Some checks failed
ci/woodpecker/push/web Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
810 lines
23 KiB
TypeScript
810 lines
23 KiB
TypeScript
"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 (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
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 */}
|
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<h3
|
|
style={{
|
|
fontWeight: 600,
|
|
color: "var(--text)",
|
|
fontSize: "1rem",
|
|
margin: 0,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{project.name}
|
|
</h3>
|
|
</div>
|
|
|
|
{/* Delete button */}
|
|
<button
|
|
aria-label={`Delete project ${project.name}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete(project.id);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
padding: 4,
|
|
borderRadius: "var(--r-sm)",
|
|
color: "var(--muted)",
|
|
transition: "color 0.15s, background 0.15s",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
marginLeft: 8,
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.color = "var(--danger)";
|
|
e.currentTarget.style.background = "rgba(229,72,77,0.1)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.color = "var(--muted)";
|
|
e.currentTarget.style.background = "transparent";
|
|
}}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
{project.description ? (
|
|
<p
|
|
style={{
|
|
color: "var(--muted)",
|
|
fontSize: "0.85rem",
|
|
margin: 0,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
display: "-webkit-box",
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: "vertical",
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
{project.description}
|
|
</p>
|
|
) : (
|
|
<p style={{ color: "var(--muted)", fontSize: "0.85rem", margin: 0, fontStyle: "italic" }}>
|
|
No description
|
|
</p>
|
|
)}
|
|
|
|
{/* Footer: status + timestamps */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
marginTop: "auto",
|
|
}}
|
|
>
|
|
{/* Status badge */}
|
|
<span
|
|
style={{
|
|
display: "inline-block",
|
|
padding: "2px 10px",
|
|
borderRadius: "var(--r)",
|
|
background: status.bg,
|
|
color: status.color,
|
|
fontSize: "0.75rem",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{status.label}
|
|
</span>
|
|
|
|
{/* Timestamps */}
|
|
<span
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
color: "var(--muted)",
|
|
fontFamily: "var(--mono)",
|
|
}}
|
|
>
|
|
{formatTimestamp(project.createdAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Create Project Dialog
|
|
--------------------------------------------------------------------------- */
|
|
|
|
interface CreateDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (data: CreateProjectDto) => Promise<void>;
|
|
isSubmitting: boolean;
|
|
}
|
|
|
|
function CreateProjectDialog({
|
|
open,
|
|
onOpenChange,
|
|
onSubmit,
|
|
isSubmitting,
|
|
}: CreateDialogProps): ReactElement {
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
|
|
function resetForm(): void {
|
|
setName("");
|
|
setDescription("");
|
|
setFormError(null);
|
|
}
|
|
|
|
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
|
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 (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(isOpen) => {
|
|
if (!isOpen) resetForm();
|
|
onOpenChange(isOpen);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<div
|
|
style={{
|
|
background: "var(--surface)",
|
|
borderRadius: "var(--r-lg)",
|
|
border: "1px solid var(--border)",
|
|
padding: 24,
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<span style={{ color: "var(--text)" }}>New Project</span>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<span style={{ color: "var(--muted)" }}>
|
|
Give your project a name and optional description.
|
|
</span>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form
|
|
onSubmit={(e) => {
|
|
void handleSubmit(e);
|
|
}}
|
|
style={{ marginTop: 16 }}
|
|
>
|
|
{/* Name */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label
|
|
htmlFor="project-name"
|
|
style={{
|
|
display: "block",
|
|
marginBottom: 6,
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
color: "var(--text-2)",
|
|
}}
|
|
>
|
|
Name <span style={{ color: "var(--danger)" }}>*</span>
|
|
</label>
|
|
<input
|
|
id="project-name"
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => {
|
|
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",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label
|
|
htmlFor="project-description"
|
|
style={{
|
|
display: "block",
|
|
marginBottom: 6,
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
color: "var(--text-2)",
|
|
}}
|
|
>
|
|
Description
|
|
</label>
|
|
<textarea
|
|
id="project-description"
|
|
value={description}
|
|
onChange={(e) => {
|
|
setDescription(e.target.value);
|
|
}}
|
|
placeholder="A brief summary of this project..."
|
|
rows={3}
|
|
maxLength={10000}
|
|
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",
|
|
resize: "vertical",
|
|
fontFamily: "inherit",
|
|
boxSizing: "border-box",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Form error */}
|
|
{formError !== null && (
|
|
<p style={{ color: "var(--danger)", fontSize: "0.85rem", margin: "0 0 12px" }}>
|
|
{formError}
|
|
</p>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
}}
|
|
disabled={isSubmitting}
|
|
style={{
|
|
padding: "8px 16px",
|
|
background: "transparent",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r)",
|
|
color: "var(--text-2)",
|
|
fontSize: "0.85rem",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmitting || !name.trim()}
|
|
style={{
|
|
padding: "8px 16px",
|
|
background: "var(--primary)",
|
|
border: "none",
|
|
borderRadius: "var(--r)",
|
|
color: "#fff",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
cursor: isSubmitting || !name.trim() ? "not-allowed" : "pointer",
|
|
opacity: isSubmitting || !name.trim() ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{isSubmitting ? "Creating..." : "Create Project"}
|
|
</button>
|
|
</DialogFooter>
|
|
</form>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Delete Confirmation Dialog
|
|
--------------------------------------------------------------------------- */
|
|
|
|
interface DeleteDialogProps {
|
|
open: boolean;
|
|
projectName: string;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
isDeleting: boolean;
|
|
}
|
|
|
|
function DeleteConfirmDialog({
|
|
open,
|
|
projectName,
|
|
onConfirm,
|
|
onCancel,
|
|
isDeleting,
|
|
}: DeleteDialogProps): ReactElement {
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(isOpen) => {
|
|
if (!isOpen) onCancel();
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<div
|
|
style={{
|
|
background: "var(--surface)",
|
|
borderRadius: "var(--r-lg)",
|
|
border: "1px solid var(--border)",
|
|
padding: 24,
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
<span style={{ color: "var(--text)" }}>Delete Project</span>
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<span style={{ color: "var(--muted)" }}>
|
|
{"This will permanently delete "}
|
|
<strong style={{ color: "var(--text)" }}>{projectName}</strong>
|
|
{". This action cannot be undone."}
|
|
</span>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogFooter>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={isDeleting}
|
|
style={{
|
|
padding: "8px 16px",
|
|
background: "transparent",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r)",
|
|
color: "var(--text-2)",
|
|
fontSize: "0.85rem",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
disabled={isDeleting}
|
|
style={{
|
|
padding: "8px 16px",
|
|
background: "var(--danger)",
|
|
border: "none",
|
|
borderRadius: "var(--r)",
|
|
color: "#fff",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
cursor: isDeleting ? "not-allowed" : "pointer",
|
|
opacity: isDeleting ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{isDeleting ? "Deleting..." : "Delete"}
|
|
</button>
|
|
</DialogFooter>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Projects Page
|
|
--------------------------------------------------------------------------- */
|
|
|
|
export default function ProjectsPage(): ReactElement {
|
|
const router = useRouter();
|
|
const workspaceId = useWorkspaceId();
|
|
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Create dialog state
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
|
|
// Delete dialog state
|
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const loadProjects = useCallback(async (wsId: string | null): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const data = await fetchProjects(wsId ?? undefined);
|
|
setProjects(data);
|
|
} catch (err: unknown) {
|
|
console.error("[Projects] Failed to fetch projects:", err);
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Something went wrong loading projects. You could try again when ready."
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!workspaceId) {
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const wsId = workspaceId;
|
|
|
|
async function load(): Promise<void> {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const data = await fetchProjects(wsId);
|
|
if (!cancelled) {
|
|
setProjects(data);
|
|
}
|
|
} catch (err: unknown) {
|
|
console.error("[Projects] Failed to fetch projects:", err);
|
|
if (!cancelled) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Something went wrong loading projects. You could try again when ready."
|
|
);
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void load();
|
|
|
|
return (): void => {
|
|
cancelled = true;
|
|
};
|
|
}, [workspaceId]);
|
|
|
|
function handleRetry(): void {
|
|
void loadProjects(workspaceId);
|
|
}
|
|
|
|
async function handleCreate(data: CreateProjectDto): Promise<void> {
|
|
setIsCreating(true);
|
|
try {
|
|
await createProject(data, workspaceId ?? undefined);
|
|
setCreateOpen(false);
|
|
void loadProjects(workspaceId);
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
}
|
|
|
|
function handleDeleteRequest(projectId: string): void {
|
|
const target = projects.find((p) => p.id === projectId);
|
|
if (target) {
|
|
setDeleteTarget(target);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteConfirm(): Promise<void> {
|
|
if (!deleteTarget) return;
|
|
setIsDeleting(true);
|
|
try {
|
|
await deleteProject(deleteTarget.id, workspaceId ?? undefined);
|
|
setDeleteTarget(null);
|
|
void loadProjects(workspaceId);
|
|
} catch (err: unknown) {
|
|
console.error("[Projects] Failed to delete project:", err);
|
|
setError(err instanceof Error ? err.message : "Failed to delete project.");
|
|
setDeleteTarget(null);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
}
|
|
|
|
function handleCardClick(projectId: string): void {
|
|
router.push(`/projects/${projectId}`);
|
|
}
|
|
|
|
return (
|
|
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
justifyContent: "space-between",
|
|
marginBottom: 32,
|
|
flexWrap: "wrap",
|
|
gap: 16,
|
|
}}
|
|
>
|
|
<div>
|
|
<h1
|
|
style={{
|
|
fontSize: "1.875rem",
|
|
fontWeight: 700,
|
|
color: "var(--text)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
Projects
|
|
</h1>
|
|
<p
|
|
style={{
|
|
fontSize: "0.9rem",
|
|
color: "var(--muted)",
|
|
marginTop: 4,
|
|
}}
|
|
>
|
|
Organize and track your work across different initiatives
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => {
|
|
setCreateOpen(true);
|
|
}}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "8px 16px",
|
|
background: "var(--primary)",
|
|
border: "none",
|
|
borderRadius: "var(--r)",
|
|
color: "#fff",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Plus size={16} />
|
|
New Project
|
|
</button>
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{isLoading ? (
|
|
<div className="flex justify-center py-16">
|
|
<MosaicSpinner label="Loading projects..." />
|
|
</div>
|
|
) : error !== null ? (
|
|
/* Error */
|
|
<div
|
|
style={{
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r-lg)",
|
|
padding: 32,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<p style={{ color: "var(--danger)", margin: "0 0 16px" }}>{error}</p>
|
|
<button
|
|
onClick={handleRetry}
|
|
style={{
|
|
padding: "8px 16px",
|
|
background: "var(--danger)",
|
|
border: "none",
|
|
borderRadius: "var(--r)",
|
|
color: "#fff",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Try again
|
|
</button>
|
|
</div>
|
|
) : projects.length === 0 ? (
|
|
/* Empty */
|
|
<div
|
|
style={{
|
|
background: "var(--surface)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "var(--r-lg)",
|
|
padding: 48,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<p style={{ color: "var(--muted)", margin: "0 0 16px", fontSize: "0.9rem" }}>
|
|
No projects yet. Create your first project to get started.
|
|
</p>
|
|
<button
|
|
onClick={() => {
|
|
setCreateOpen(true);
|
|
}}
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
padding: "8px 16px",
|
|
background: "var(--primary)",
|
|
border: "none",
|
|
borderRadius: "var(--r)",
|
|
color: "#fff",
|
|
fontSize: "0.85rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<Plus size={16} />
|
|
Create Project
|
|
</button>
|
|
</div>
|
|
) : (
|
|
/* Projects grid */
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{projects.map((project) => (
|
|
<ProjectCard
|
|
key={project.id}
|
|
project={project}
|
|
onDelete={handleDeleteRequest}
|
|
onClick={handleCardClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Dialog */}
|
|
<CreateProjectDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
onSubmit={handleCreate}
|
|
isSubmitting={isCreating}
|
|
/>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<DeleteConfirmDialog
|
|
open={deleteTarget !== null}
|
|
projectName={deleteTarget?.name ?? ""}
|
|
onConfirm={() => {
|
|
void handleDeleteConfirm();
|
|
}}
|
|
onCancel={() => {
|
|
setDeleteTarget(null);
|
|
}}
|
|
isDeleting={isDeleting}
|
|
/>
|
|
</main>
|
|
);
|
|
}
|