feat: projects with context - schema, API, frontend page, task assignment (HQ-17, HQ-21)

This commit is contained in:
2026-01-29 05:05:20 +00:00
parent 8685548206
commit b0559cdbc8
10 changed files with 963 additions and 6 deletions

View File

@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { DashboardLayout } from "./components/DashboardLayout";
import { QueuePage } from "./pages/QueuePage";
import { ChatPage } from "./pages/ChatPage";
import { ProjectsPage } from "./pages/ProjectsPage";
import { AdminPage } from "./components/AdminPage";
import { LoginPage } from "./components/LoginPage";
import { useSession } from "./lib/auth-client";
@@ -12,6 +13,7 @@ function AuthenticatedApp() {
<Routes>
<Route element={<DashboardLayout />}>
<Route path="/queue" element={<QueuePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="*" element={<Navigate to="/queue" replace />} />

View File

@@ -5,6 +5,7 @@ import { signOut } from "../lib/auth-client";
const navItems = [
{ to: "/queue", label: "Queue", icon: "📋" },
{ to: "/projects", label: "Projects", icon: "📁" },
{ to: "/chat", label: "Chat", icon: "💬" },
];

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import type { Task, TaskStatus, TaskPriority, TaskSource } from "../lib/types";
import { updateTask } from "../lib/api";
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types";
import { updateTask, fetchProjects } from "../lib/api";
const priorityColors: Record<TaskPriority, string> = {
critical: "bg-red-500 text-white",
@@ -250,6 +250,13 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
const [draftDescription, setDraftDescription] = useState(task.description || "");
const [draftPriority, setDraftPriority] = useState(task.priority);
const [draftSource, setDraftSource] = useState(task.source);
const [draftProjectId, setDraftProjectId] = useState(task.projectId || "");
const [projects, setProjects] = useState<Project[]>([]);
// Fetch projects for the selector
useEffect(() => {
fetchProjects().then(setProjects).catch(() => {});
}, []);
// Reset drafts when task changes
useEffect(() => {
@@ -257,31 +264,35 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
setDraftDescription(task.description || "");
setDraftPriority(task.priority);
setDraftSource(task.source);
}, [task.id, task.title, task.description, task.priority, task.source]);
setDraftProjectId(task.projectId || "");
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId]);
// Detect if any field has been modified
const isDirty =
draftTitle !== task.title ||
draftDescription !== (task.description || "") ||
draftPriority !== task.priority ||
draftSource !== task.source;
draftSource !== task.source ||
draftProjectId !== (task.projectId || "");
const handleCancel = () => {
setDraftTitle(task.title);
setDraftDescription(task.description || "");
setDraftPriority(task.priority);
setDraftSource(task.source);
setDraftProjectId(task.projectId || "");
};
const handleSave = async () => {
if (!hasToken || !isDirty) return;
setSaving(true);
try {
const updates: Record<string, string> = {};
const updates: Record<string, string | null> = {};
if (draftTitle !== task.title) updates.title = draftTitle.trim();
if (draftDescription !== (task.description || "")) updates.description = draftDescription.trim();
if (draftPriority !== task.priority) updates.priority = draftPriority;
if (draftSource !== task.source) updates.source = draftSource;
if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null;
await updateTask(task.id, updates, token);
onTaskUpdated();
} catch (e) {
@@ -426,6 +437,29 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div>
</div>
{/* Project */}
{(hasToken || task.projectId) && (
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Project</h3>
{hasToken ? (
<select
value={draftProjectId}
onChange={(e) => setDraftProjectId(e.target.value)}
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white w-full max-w-xs"
>
<option value="">No project</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
) : (
<span className="text-sm text-gray-700">
{projects.find((p) => p.id === task.projectId)?.name || task.projectId || "None"}
</span>
)}
</div>
)}
{/* Description */}
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>

View File

@@ -1,4 +1,4 @@
import type { Task } from "./types";
import type { Task, Project, ProjectWithTasks } from "./types";
const BASE = "/api/tasks";
@@ -64,6 +64,57 @@ export async function deleteTask(id: string, token?: string): Promise<void> {
if (!res.ok) throw new Error("Failed to delete task");
}
// ─── Projects API ───
const PROJECTS_BASE = "/api/projects";
export async function fetchProjects(): Promise<Project[]> {
const res = await fetch(PROJECTS_BASE, { credentials: "include" });
if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch projects");
return res.json();
}
export async function fetchProject(id: string): Promise<ProjectWithTasks> {
const res = await fetch(`${PROJECTS_BASE}/${id}`, { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch project");
return res.json();
}
export async function createProject(
project: { name: string; description?: string; context?: string; repos?: string[]; links?: { label: string; url: string }[] }
): Promise<Project> {
const res = await fetch(PROJECTS_BASE, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(project),
});
if (!res.ok) throw new Error("Failed to create project");
return res.json();
}
export async function updateProject(
id: string,
updates: Record<string, any>
): Promise<Project> {
const res = await fetch(`${PROJECTS_BASE}/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error("Failed to update project");
return res.json();
}
export async function deleteProject(id: string): Promise<void> {
const res = await fetch(`${PROJECTS_BASE}/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to delete project");
}
// Admin API
export async function fetchUsers(): Promise<any[]> {
const res = await fetch("/api/admin/users", { credentials: "include" });

View File

@@ -7,6 +7,26 @@ export interface ProgressNote {
note: string;
}
export interface ProjectLink {
label: string;
url: string;
}
export interface Project {
id: string;
name: string;
description: string | null;
context: string | null;
repos: string[];
links: ProjectLink[];
createdAt: string;
updatedAt: string;
}
export interface ProjectWithTasks extends Project {
tasks: Task[];
}
export interface Task {
id: string;
taskNumber: number;
@@ -16,6 +36,7 @@ export interface Task {
status: TaskStatus;
priority: TaskPriority;
position: number;
projectId: string | null;
progressNotes: ProgressNote[];
createdAt: string;
updatedAt: string;

View File

@@ -0,0 +1,665 @@
import { useState, useEffect, useCallback } from "react";
import type { Project, ProjectWithTasks, Task } from "../lib/types";
import {
fetchProjects,
fetchProject,
createProject,
updateProject,
updateTask,
} from "../lib/api";
// ─── Status/priority helpers ───
const statusColors: Record<string, string> = {
active: "bg-green-100 text-green-700",
queued: "bg-blue-100 text-blue-700",
blocked: "bg-red-100 text-red-700",
completed: "bg-gray-100 text-gray-500",
cancelled: "bg-gray-100 text-gray-400",
};
function StatusBadge({ status }: { status: string }) {
return (
<span
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase ${statusColors[status] || "bg-gray-100 text-gray-500"}`}
>
{status}
</span>
);
}
// ─── Create Project Modal ───
function CreateProjectModal({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (p: Project) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [context, setContext] = useState("");
const [repos, setRepos] = useState("");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
const repoList = repos
.split("\n")
.map((r) => r.trim())
.filter(Boolean);
const project = await createProject({
name: name.trim(),
description: description.trim() || undefined,
context: context.trim() || undefined,
repos: repoList.length ? repoList : undefined,
});
onCreated(project);
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-900">New Project</h2>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200"
placeholder="e.g. Hammer Dashboard"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
placeholder="Brief description of the project"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Context{" "}
<span className="text-gray-400 font-normal">
(architecture, credentials refs, how-to)
</span>
</label>
<textarea
value={context}
onChange={(e) => setContext(e.target.value)}
rows={5}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
placeholder={`Repo: https://gitea.donovankelly.xyz/...\nDomain: dash.donovankelly.xyz\nDokploy Compose ID: ...\nStack: React + Vite, Elysia + Bun, Postgres + Drizzle`}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Repos{" "}
<span className="text-gray-400 font-normal">(one per line)</span>
</label>
<textarea
value={repos}
onChange={(e) => setRepos(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
placeholder="https://gitea.donovankelly.xyz/donovan/hammer-queue"
/>
</div>
</div>
<div className="px-6 py-3 border-t border-gray-100 flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="px-4 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 transition font-medium"
>
{saving ? "Creating..." : "Create Project"}
</button>
</div>
</div>
</div>
);
}
// ─── Project Detail View ───
function ProjectDetail({
projectId,
onBack,
allTasks,
onTasksChanged,
}: {
projectId: string;
onBack: () => void;
allTasks: Task[];
onTasksChanged: () => void;
}) {
const [project, setProject] = useState<ProjectWithTasks | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const [editContext, setEditContext] = useState("");
const [editRepos, setEditRepos] = useState("");
const [saving, setSaving] = useState(false);
const [showAssign, setShowAssign] = useState(false);
const load = useCallback(async () => {
try {
const data = await fetchProject(projectId);
setProject(data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
load();
}, [load]);
const startEdit = () => {
if (!project) return;
setEditName(project.name);
setEditDesc(project.description || "");
setEditContext(project.context || "");
setEditRepos((project.repos || []).join("\n"));
setEditing(true);
};
const saveEdit = async () => {
if (!project) return;
setSaving(true);
try {
const repoList = editRepos
.split("\n")
.map((r) => r.trim())
.filter(Boolean);
await updateProject(project.id, {
name: editName.trim(),
description: editDesc.trim() || null,
context: editContext.trim() || null,
repos: repoList,
});
setEditing(false);
load();
} catch (e) {
console.error(e);
} finally {
setSaving(false);
}
};
const assignTask = async (taskId: string) => {
try {
await updateTask(taskId, { projectId });
load();
onTasksChanged();
} catch (e) {
console.error(e);
}
};
const unassignTask = async (taskId: string) => {
try {
await updateTask(taskId, { projectId: null });
load();
onTasksChanged();
} catch (e) {
console.error(e);
}
};
if (loading) {
return (
<div className="p-8 text-center text-gray-400">Loading project...</div>
);
}
if (!project) {
return (
<div className="p-8 text-center text-gray-400">Project not found</div>
);
}
// Unassigned tasks (not in this project)
const unassignedTasks = allTasks.filter(
(t) =>
!t.projectId &&
t.status !== "completed" &&
t.status !== "cancelled"
);
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={onBack}
className="p-1.5 rounded-lg hover:bg-gray-100 transition text-gray-400"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex-1">
{editing ? (
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="text-2xl font-bold text-gray-900 border-b-2 border-amber-400 focus:outline-none w-full"
/>
) : (
<h1 className="text-2xl font-bold text-gray-900">
{project.name}
</h1>
)}
</div>
{!editing && (
<button
onClick={startEdit}
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-3 py-1.5 rounded-lg hover:bg-amber-50 transition"
>
Edit
</button>
)}
</div>
{/* Edit mode */}
{editing ? (
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Context
</label>
<textarea
value={editContext}
onChange={(e) => setEditContext(e.target.value)}
rows={8}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Repos (one per line)
</label>
<textarea
value={editRepos}
onChange={(e) => setEditRepos(e.target.value)}
rows={2}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditing(false)}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={saveEdit}
disabled={saving || !editName.trim()}
className="px-4 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 font-medium"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
) : (
<>
{/* Description */}
{project.description && (
<p className="text-sm text-gray-600 mb-4">{project.description}</p>
)}
{/* Context card */}
{project.context && (
<div className="bg-gray-50 rounded-xl border border-gray-200 p-5 mb-6">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
📋 Context
</h3>
<pre className="text-sm text-gray-700 whitespace-pre-wrap font-mono leading-relaxed">
{project.context}
</pre>
</div>
)}
{/* Repos */}
{project.repos && project.repos.length > 0 && (
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
📦 Repos
</h3>
<div className="space-y-1">
{project.repos.map((repo, i) => (
<a
key={i}
href={repo}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-blue-600 hover:text-blue-800 font-mono truncate"
>
{repo}
</a>
))}
</div>
</div>
)}
{/* Links */}
{project.links && project.links.length > 0 && (
<div className="mb-6">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
🔗 Links
</h3>
<div className="space-y-1">
{project.links.map((link, i) => (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-blue-600 hover:text-blue-800"
>
{link.label}{" "}
<span className="text-gray-400 font-mono text-xs">
({link.url})
</span>
</a>
))}
</div>
</div>
)}
</>
)}
{/* Tasks section */}
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
📝 Tasks ({project.tasks?.length || 0})
</h3>
<button
onClick={() => setShowAssign(!showAssign)}
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-2 py-1 rounded hover:bg-amber-50 transition"
>
{showAssign ? "Done" : "+ Assign Task"}
</button>
</div>
{/* Assign task dropdown */}
{showAssign && unassignedTasks.length > 0 && (
<div className="bg-white rounded-xl border border-amber-200 p-3 mb-4 max-h-48 overflow-y-auto">
<p className="text-xs text-gray-400 mb-2">
Select a task to assign to this project:
</p>
{unassignedTasks.map((task) => (
<button
key={task.id}
onClick={() => assignTask(task.id)}
className="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-amber-50 rounded-lg transition flex items-center gap-2"
>
<span className="text-gray-400 text-xs font-mono">
HQ-{task.taskNumber}
</span>
<span className="truncate">{task.title}</span>
<StatusBadge status={task.status} />
</button>
))}
</div>
)}
{showAssign && unassignedTasks.length === 0 && (
<div className="bg-gray-50 rounded-lg p-3 mb-4 text-xs text-gray-400 text-center">
All tasks are already assigned to projects
</div>
)}
{/* Task list */}
{project.tasks && project.tasks.length > 0 ? (
<div className="space-y-2">
{project.tasks.map((task) => (
<div
key={task.id}
className="bg-white rounded-lg border border-gray-200 px-4 py-3 flex items-center gap-3"
>
<span className="text-xs text-gray-400 font-mono shrink-0">
HQ-{task.taskNumber}
</span>
<span className="text-sm text-gray-800 flex-1 truncate">
{task.title}
</span>
<StatusBadge status={task.status} />
<button
onClick={() => unassignTask(task.id)}
className="text-gray-300 hover:text-red-400 transition shrink-0"
title="Remove from project"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
) : (
<div className="text-sm text-gray-400 text-center py-6 bg-gray-50 rounded-lg">
No tasks assigned to this project yet
</div>
)}
</div>
</div>
);
}
// ─── Project Card ───
function ProjectCard({
project,
taskCount,
onClick,
}: {
project: Project;
taskCount: number;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className="w-full text-left bg-white rounded-xl border border-gray-200 p-5 hover:border-amber-300 hover:shadow-sm transition group"
>
<div className="flex items-start justify-between mb-2">
<h3 className="text-base font-semibold text-gray-900 group-hover:text-amber-700 transition">
{project.name}
</h3>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full shrink-0 ml-2">
{taskCount} task{taskCount !== 1 ? "s" : ""}
</span>
</div>
{project.description && (
<p className="text-sm text-gray-500 line-clamp-2 mb-3">
{project.description}
</p>
)}
<div className="flex items-center gap-3">
{project.repos && project.repos.length > 0 && (
<span className="text-xs text-gray-400">
📦 {project.repos.length} repo
{project.repos.length !== 1 ? "s" : ""}
</span>
)}
{project.context && (
<span className="text-xs text-gray-400">📋 Has context</span>
)}
</div>
</button>
);
}
// ─── Main Page ───
export function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]);
const [allTasks, setAllTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const loadAll = useCallback(async () => {
try {
const [projectsData, tasksRes] = await Promise.all([
fetchProjects(),
fetch("/api/tasks", { credentials: "include" }).then((r) => r.json()),
]);
setProjects(projectsData);
setAllTasks(tasksRes);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadAll();
}, [loadAll]);
// Count tasks per project
const taskCountMap: Record<string, number> = {};
for (const task of allTasks) {
if (task.projectId) {
taskCountMap[task.projectId] = (taskCountMap[task.projectId] || 0) + 1;
}
}
if (selectedProject) {
return (
<div className="p-4 sm:p-6">
<ProjectDetail
projectId={selectedProject}
onBack={() => {
setSelectedProject(null);
loadAll();
}}
allTasks={allTasks}
onTasksChanged={loadAll}
/>
</div>
);
}
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">
Projects
</h1>
<p className="text-sm text-gray-500 mt-1">
Organize tasks by project with context for autonomous work
</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
>
+ New Project
</button>
</div>
{loading ? (
<div className="text-center text-gray-400 py-12">
Loading projects...
</div>
) : projects.length === 0 ? (
<div className="text-center py-16">
<span className="text-5xl block mb-4">📁</span>
<h2 className="text-lg font-semibold text-gray-600 mb-2">
No projects yet
</h2>
<p className="text-sm text-gray-400 mb-4">
Create a project to group tasks and add context
</p>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
>
Create First Project
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
taskCount={taskCountMap[project.id] || 0}
onClick={() => setSelectedProject(project.id)}
/>
))}
</div>
)}
{showCreate && (
<CreateProjectModal
onClose={() => setShowCreate(false)}
onCreated={(p) => {
setShowCreate(false);
setProjects((prev) => [...prev, p]);
}}
/>
)}
</div>
);
}