added project

Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
Max 2026-02-23 14:54:22 -06:00
parent 60aed51f3c
commit 2797256bd7
4 changed files with 1266 additions and 4 deletions

View File

@ -63,9 +63,8 @@ const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"]; const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Optimized field selection - only fetch fields needed for board display // Optimized field selection - fetch all fields needed for board and detail display
// Full task details (description, comments, attachments) fetched lazily const TASK_FIELDS = [
const TASK_FIELDS_LIGHT = [
"id", "id",
"title", "title",
"type", "type",
@ -80,6 +79,9 @@ const TASK_FIELDS_LIGHT = [
"assignee_id", "assignee_id",
"due_date", "due_date",
"tags", "tags",
"comments",
"attachments",
"description",
]; ];
class HttpError extends Error { class HttpError extends Error {
@ -287,7 +289,7 @@ export async function GET() {
] = await Promise.all([ ] = await Promise.all([
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }), supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }), supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
supabase.from("tasks").select(TASK_FIELDS_LIGHT.join(", ")).order("updated_at", { ascending: false }), supabase.from("tasks").select(TASK_FIELDS.join(", ")).order("updated_at", { ascending: false }),
supabase.from("users").select("id, name, email, avatar_url"), supabase.from("users").select("id, name, email, avatar_url"),
]); ]);

View File

@ -0,0 +1,760 @@
"use client"
import { useEffect, useState, useMemo } from "react"
import { useRouter, useParams } from "next/navigation"
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from "@dnd-kit/core"
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
ArrowLeft,
FolderKanban,
Plus,
Edit3,
Check,
X,
GripVertical,
LayoutGrid,
ListTodo,
Trash2,
MessageSquare,
Inbox
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useTaskStore, Task, Project } from "@/stores/useTaskStore"
import { toast } from "sonner"
import { parseSprintStart } from "@/lib/utils"
const PRESET_COLORS = [
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
"#ec4899", "#06b6d4", "#f97316", "#84cc16", "#6366f1",
]
const priorityColors: Record<string, string> = {
low: "bg-slate-600",
medium: "bg-blue-600",
high: "bg-orange-600",
urgent: "bg-red-600",
}
const statusLabels: Record<string, string> = {
open: "Open",
todo: "To Do",
blocked: "Blocked",
"in-progress": "In Progress",
review: "Review",
validate: "Validate",
archived: "Archived",
canceled: "Canceled",
done: "Done",
}
// Sortable Task Card Component
function SortableTaskCard({
task,
onClick,
}: {
task: Task
onClick: () => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
return (
<Card
ref={setNodeRef}
style={style}
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-slate-600 transition-colors group"
onClick={onClick}
>
<CardContent className="p-3">
<div className="flex items-start gap-2">
<div
{...attributes}
{...listeners}
className="mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
>
<GripVertical className="w-4 h-4 text-slate-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
{task.title}
</h4>
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<Badge
className={`${priorityColors[task.priority]} text-white text-xs`}
>
{task.priority}
</Badge>
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{task.type}
</Badge>
<Badge variant="secondary" className="text-xs bg-slate-700 text-slate-300">
{statusLabels[task.status]}
</Badge>
{task.comments && task.comments.length > 0 && (
<span className="text-xs text-slate-500 flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
{task.comments.length}
</span>
)}
</div>
{task.assigneeName && (
<p className="text-xs text-slate-500 mt-2">
Assigned to: {task.assigneeName}
</p>
)}
</div>
</div>
</CardContent>
</Card>
)
}
// Drag Overlay Task Card
function DragOverlayTaskCard({ task }: { task: Task }) {
return (
<Card className="bg-slate-800 border-slate-600 shadow-xl rotate-2">
<CardContent className="p-3">
<div className="flex items-start gap-2">
<GripVertical className="w-4 h-4 text-slate-500 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
{task.title}
</h4>
<div className="flex items-center gap-2 mt-2">
<Badge className={`${priorityColors[task.priority]} text-white text-xs`}>
{task.priority}
</Badge>
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{task.type}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
// Unassigned Tasks Panel Component
function UnassignedPanel({
tasks,
onTaskClick,
}: {
tasks: Task[]
onTaskClick: (taskId: string) => void
}) {
const { setNodeRef, isOver } = useSortable({
id: "unassigned-drop-zone",
data: { type: "unassigned" },
})
return (
<div
ref={setNodeRef}
className={`bg-slate-900/50 rounded-lg p-4 border-2 border-dashed transition-colors ${
isOver ? "border-blue-500 bg-blue-500/10" : "border-slate-700"
}`}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Inbox className="w-4 h-4 text-slate-400" />
<h3 className="font-medium text-slate-300">Unassigned Tasks</h3>
</div>
<Badge variant="secondary" className="text-xs bg-slate-800 text-slate-400">
{tasks.length}
</Badge>
</div>
<SortableContext items={tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 min-h-[100px]">
{tasks.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-sm">
<p>Drop tasks here to unassign</p>
<p className="text-xs mt-1">from this project</p>
</div>
) : (
tasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
onClick={() => onTaskClick(task.id)}
/>
))
)}
</div>
</SortableContext>
</div>
)
}
export default function ProjectDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const projectId = params.id
const {
projects,
tasks,
sprints,
currentUser,
updateTask,
updateProject,
deleteProject,
syncFromServer,
addTask,
} = useTaskStore()
const [authReady, setAuthReady] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [project, setProject] = useState<Project | null>(null)
// Edit state
const [isEditing, setIsEditing] = useState(false)
const [editName, setEditName] = useState("")
const [editDescription, setEditDescription] = useState("")
const [editColor, setEditColor] = useState("")
// Drag and drop
const [activeId, setActiveId] = useState<string | null>(null)
// New task dialog
const [newTaskOpen, setNewTaskOpen] = useState(false)
const [newTask, setNewTask] = useState<Partial<Task>>({
title: "",
description: "",
type: "task",
priority: "medium",
status: "open",
})
// Sensors for drag detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
)
// Check auth
useEffect(() => {
let isMounted = true
const loadSession = async () => {
try {
const res = await fetch("/api/auth/session", { cache: "no-store" })
if (!res.ok) {
if (isMounted) router.replace("/login")
return
}
if (isMounted) setAuthReady(true)
} catch {
if (isMounted) router.replace("/login")
}
}
loadSession()
return () => { isMounted = false }
}, [router])
// Sync and find project
useEffect(() => {
if (!authReady) return
const load = async () => {
await syncFromServer()
const found = projects.find((p) => p.id === projectId)
if (found) {
setProject(found)
setEditName(found.name)
setEditDescription(found.description || "")
setEditColor(found.color)
}
setIsLoading(false)
}
load()
}, [authReady, projectId, projects, syncFromServer])
// Get project tasks
const projectTasks = useMemo(() => {
return tasks
.filter((t) => t.projectId === projectId)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}, [tasks, projectId])
// Get unassigned tasks (for this user's view - tasks without project)
const unassignedTasks = useMemo(() => {
return tasks
.filter((t) => !t.projectId && t.createdById === currentUser.id)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}, [tasks, currentUser.id])
// Get project sprints
const projectSprints = useMemo(() => {
return sprints
.filter((s) => s.projectId === projectId)
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
}, [sprints, projectId])
// Stats
const stats = useMemo(() => {
const total = projectTasks.length
const completed = projectTasks.filter((t) => t.status === "done" || t.status === "archived").length
const inProgress = projectTasks.filter((t) => t.status === "in-progress" || t.status === "review").length
const todo = projectTasks.filter((t) => t.status === "open" || t.status === "todo").length
return { total, completed, inProgress, todo }
}, [projectTasks])
const handleSaveEdit = async () => {
if (!project || !editName.trim()) {
toast.error("Project name is required")
return
}
try {
await updateProject(project.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
color: editColor,
})
toast.success("Project updated successfully")
setIsEditing(false)
} catch (error) {
toast.error("Failed to update project")
console.error(error)
}
}
const handleDeleteProject = async () => {
if (!project) return
if (!window.confirm("Are you sure you want to delete this project? This cannot be undone.")) return
try {
await deleteProject(project.id)
toast.success("Project deleted successfully")
router.push("/projects")
} catch (error) {
toast.error("Failed to delete project")
console.error(error)
}
}
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string)
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveId(null)
if (!over) return
const taskId = active.id as string
const overId = over.id as string
// If dropped on unassigned zone, remove project association
if (overId === "unassigned-drop-zone") {
const task = tasks.find((t) => t.id === taskId)
if (task && task.projectId === projectId) {
updateTask(taskId, { projectId: undefined, sprintId: undefined })
toast.success("Task unassigned from project")
}
return
}
// If dropped on project zone, assign to this project
if (overId === "project-drop-zone") {
const task = tasks.find((t) => t.id === taskId)
if (task && task.projectId !== projectId) {
updateTask(taskId, { projectId })
toast.success("Task assigned to project")
}
return
}
// If dropped on another task, we could reorder (future enhancement)
const overTask = tasks.find((t) => t.id === overId)
if (overTask) {
// Check if we need to move between projects
const activeTask = tasks.find((t) => t.id === taskId)
if (activeTask && overTask.projectId !== activeTask.projectId) {
if (overTask.projectId === projectId) {
updateTask(taskId, { projectId })
toast.success("Task assigned to project")
} else if (!overTask.projectId) {
updateTask(taskId, { projectId: undefined, sprintId: undefined })
toast.success("Task unassigned from project")
}
}
}
}
const handleAddTask = async () => {
if (!newTask.title?.trim() || !project) return
try {
await addTask({
title: newTask.title.trim(),
description: newTask.description?.trim() || undefined,
type: (newTask.type || "task") as Task["type"],
priority: (newTask.priority || "medium") as Task["priority"],
status: (newTask.status || "open") as Task["status"],
projectId: project.id,
tags: [],
})
toast.success("Task created successfully")
setNewTaskOpen(false)
setNewTask({
title: "",
description: "",
type: "task",
priority: "medium",
status: "open",
})
} catch (error) {
toast.error("Failed to create task")
console.error(error)
}
}
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
if (!authReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<p className="text-sm text-slate-400">Checking session...</p>
</div>
)
}
if (isLoading) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<div className="flex items-center gap-2 text-slate-400">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Loading project...</span>
</div>
</div>
)
}
if (!project) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<div className="text-center">
<FolderKanban className="w-16 h-16 mx-auto mb-4 text-slate-600" />
<h2 className="text-xl font-semibold text-white mb-2">Project Not Found</h2>
<p className="text-slate-400 mb-4">The project you&apos;re looking for doesn&apos;t exist.</p>
<Button onClick={() => router.push("/projects")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Projects
</Button>
</div>
</div>
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */}
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<div className="max-w-[1800px] mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="outline"
className="border-slate-700 text-slate-200 hover:bg-slate-800"
onClick={() => router.push("/projects")}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
{isEditing ? (
<div className="flex items-center gap-3">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="bg-slate-800 border-slate-700 text-white w-64"
placeholder="Project name"
autoFocus
/>
<div className="flex gap-1">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setEditColor(color)}
className={`w-6 h-6 rounded-full border-2 transition-all ${
editColor === color ? "border-white" : "border-transparent"
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<Button size="sm" onClick={handleSaveEdit}>
<Check className="w-4 h-4 mr-1" />
Save
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
<X className="w-4 h-4 mr-1" />
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded-full"
style={{ backgroundColor: project.color }}
/>
<div>
<h1 className="text-xl md:text-2xl font-bold text-white">
{project.name}
</h1>
{project.description && (
<p className="text-sm text-slate-400">{project.description}</p>
)}
</div>
<button
onClick={() => setIsEditing(true)}
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="Edit project"
>
<Edit3 className="w-4 h-4" />
</button>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDeleteProject} className="text-red-400 hover:text-red-300">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
<Button onClick={() => setNewTaskOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Task
</Button>
</div>
</div>
</div>
</header>
<div className="max-w-[1800px] mx-auto px-4 py-6">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
<p className="text-3xl font-bold text-white">{stats.total}</p>
<p className="text-sm text-slate-400">Total Tasks</p>
</div>
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
<p className="text-3xl font-bold text-emerald-400">{stats.completed}</p>
<p className="text-sm text-slate-400">Completed</p>
</div>
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
<p className="text-3xl font-bold text-blue-400">{stats.inProgress}</p>
<p className="text-sm text-slate-400">In Progress</p>
</div>
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
<p className="text-3xl font-bold text-slate-400">{stats.todo}</p>
<p className="text-sm text-slate-400">To Do</p>
</div>
</div>
{/* Progress bar */}
{stats.total > 0 && (
<div className="mb-6 bg-slate-900 border border-slate-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-400">Project Progress</span>
<span className="text-sm text-white font-medium">
{Math.round((stats.completed / stats.total) * 100)}% Complete
</span>
</div>
<div className="h-3 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{
width: `${(stats.completed / stats.total) * 100}%`,
backgroundColor: project.color
}}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Project Tasks */}
<div className="lg:col-span-2">
<div
className="bg-slate-900/50 rounded-lg p-4 border border-slate-800"
id="project-drop-zone"
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<LayoutGrid className="w-4 h-4 text-slate-400" />
<h3 className="font-medium text-slate-300">Project Tasks</h3>
</div>
<Badge variant="secondary" className="text-xs bg-slate-800 text-slate-400">
{projectTasks.length}
</Badge>
</div>
{projectSprints.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
<span className="text-xs text-slate-500">Sprints:</span>
{projectSprints.map((sprint) => (
<Badge
key={sprint.id}
variant="outline"
className="text-xs border-slate-700 text-slate-400"
>
{sprint.name}
</Badge>
))}
</div>
)}
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 min-h-[200px]">
{projectTasks.length === 0 ? (
<div className="text-center py-12 text-slate-500 border-2 border-dashed border-slate-800 rounded-lg">
<ListTodo className="w-12 h-12 mx-auto mb-3 text-slate-600" />
<p>No tasks in this project yet</p>
<p className="text-sm mt-1">Drag tasks here or click &quot;Add Task&quot;</p>
</div>
) : (
projectTasks.map((task) => (
<SortableTaskCard
key={task.id}
task={task}
onClick={() => router.push(`/tasks/${task.id}`)}
/>
))
)}
</div>
</SortableContext>
</div>
</div>
{/* Unassigned Tasks Panel */}
<div className="lg:col-span-1">
<UnassignedPanel
tasks={unassignedTasks}
onTaskClick={(taskId) => router.push(`/tasks/${taskId}`)}
/>
</div>
</div>
</div>
{/* New Task Dialog */}
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-lg">
<DialogHeader>
<DialogTitle>Create New Task</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm text-slate-400">Title *</label>
<Input
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
placeholder="Task title"
className="mt-1 bg-slate-800 border-slate-700 text-white"
/>
</div>
<div>
<label className="text-sm text-slate-400">Description</label>
<Textarea
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
placeholder="Task description"
rows={3}
className="mt-1 bg-slate-800 border-slate-700 text-white"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-slate-400">Type</label>
<select
value={newTask.type}
onChange={(e) => setNewTask({ ...newTask, type: e.target.value as Task["type"] })}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
>
<option value="task">📋 Task</option>
<option value="idea">💡 Idea</option>
<option value="bug">🐛 Bug</option>
<option value="research">🔬 Research</option>
<option value="plan">📐 Plan</option>
</select>
</div>
<div>
<label className="text-sm text-slate-400">Priority</label>
<select
value={newTask.priority}
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value as Task["priority"] })}
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setNewTaskOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddTask} disabled={!newTask.title?.trim()}>
Create Task
</Button>
</div>
</DialogContent>
</Dialog>
<DragOverlay>
{activeTask ? <DragOverlayTaskCard task={activeTask} /> : null}
</DragOverlay>
</div>
</DndContext>
)
}

478
src/app/projects/page.tsx Normal file
View File

@ -0,0 +1,478 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { FolderKanban, Plus, ArrowLeft, LayoutGrid, Trash2, Edit3, Check, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { useTaskStore, Project } from "@/stores/useTaskStore"
import { toast } from "sonner"
interface ProjectWithStats extends Project {
taskCount: number
completedTasks: number
inProgressTasks: number
}
const PRESET_COLORS = [
"#3b82f6", // blue
"#ef4444", // red
"#22c55e", // green
"#f59e0b", // amber
"#8b5cf6", // violet
"#ec4899", // pink
"#06b6d4", // cyan
"#f97316", // orange
"#84cc16", // lime
"#6366f1", // indigo
]
export default function ProjectsPage() {
const router = useRouter()
const { projects, tasks, addProject, updateProject, deleteProject, syncFromServer } = useTaskStore()
const [authReady, setAuthReady] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([])
// Create project dialog state
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [newProjectName, setNewProjectName] = useState("")
const [newProjectDescription, setNewProjectDescription] = useState("")
const [newProjectColor, setNewProjectColor] = useState(PRESET_COLORS[0])
const [isCreating, setIsCreating] = useState(false)
// Edit state
const [editingProjectId, setEditingProjectId] = useState<string | null>(null)
const [editName, setEditName] = useState("")
const [editDescription, setEditDescription] = useState("")
const [editColor, setEditColor] = useState("")
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
// Check auth
useEffect(() => {
let isMounted = true
const loadSession = async () => {
try {
const res = await fetch("/api/auth/session", { cache: "no-store" })
if (!res.ok) {
if (isMounted) router.replace("/login")
return
}
if (isMounted) setAuthReady(true)
} catch {
if (isMounted) router.replace("/login")
}
}
loadSession()
return () => { isMounted = false }
}, [router])
// Sync data when auth is ready
useEffect(() => {
if (!authReady) return
syncFromServer().then(() => setIsLoading(false))
}, [authReady, syncFromServer])
// Calculate project stats
useEffect(() => {
const stats = projects.map(project => {
const projectTasks = tasks.filter(t => t.projectId === project.id)
return {
...project,
taskCount: projectTasks.length,
completedTasks: projectTasks.filter(t => t.status === "done" || t.status === "archived").length,
inProgressTasks: projectTasks.filter(t => t.status === "in-progress" || t.status === "review").length,
}
})
setProjectsWithStats(stats)
}, [projects, tasks])
const handleCreateProject = async () => {
if (!newProjectName.trim()) {
toast.error("Project name is required")
return
}
setIsCreating(true)
try {
// addProject only accepts name and description, color is assigned randomly
// We'll update the color immediately after creation
await addProject(
newProjectName.trim(),
newProjectDescription.trim() || undefined
)
// The project was created, now we need to sync and update the color
await syncFromServer()
// Find the newly created project (most recent one with matching name)
const newProject = projects
.filter(p => p.name === newProjectName.trim())
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
if (newProject && newProject.color !== newProjectColor) {
await updateProject(newProject.id, { color: newProjectColor })
}
toast.success("Project created successfully")
setCreateDialogOpen(false)
setNewProjectName("")
setNewProjectDescription("")
setNewProjectColor(PRESET_COLORS[0])
} catch (error) {
toast.error("Failed to create project")
console.error(error)
} finally {
setIsCreating(false)
}
}
const startEdit = (project: Project) => {
setEditingProjectId(project.id)
setEditName(project.name)
setEditDescription(project.description || "")
setEditColor(project.color)
}
const cancelEdit = () => {
setEditingProjectId(null)
setEditName("")
setEditDescription("")
setEditColor("")
}
const saveEdit = async (projectId: string) => {
if (!editName.trim()) {
toast.error("Project name is required")
return
}
try {
await updateProject(projectId, {
name: editName.trim(),
description: editDescription.trim() || undefined,
color: editColor,
})
toast.success("Project updated successfully")
setEditingProjectId(null)
} catch (error) {
toast.error("Failed to update project")
console.error(error)
}
}
const handleDeleteProject = async (projectId: string) => {
try {
await deleteProject(projectId)
toast.success("Project deleted successfully")
setDeleteConfirmId(null)
} catch (error) {
toast.error("Failed to delete project")
console.error(error)
}
}
if (!authReady) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<p className="text-sm text-slate-400">Checking session...</p>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */}
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<div className="max-w-[1800px] mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="outline"
className="border-slate-700 text-slate-200 hover:bg-slate-800"
onClick={() => router.push("/")}
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Board
</Button>
<div>
<h1 className="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
Projects
</h1>
<p className="text-xs md:text-sm text-slate-400 mt-1">
Manage your projects and view task statistics
</p>
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</div>
</div>
</header>
<div className="max-w-[1800px] mx-auto px-4 py-6">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-2 text-slate-400">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Loading projects...</span>
</div>
</div>
) : projectsWithStats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<FolderKanban className="w-16 h-16 mb-4 text-slate-600" />
<h3 className="text-lg font-medium text-slate-300 mb-2">No Projects Yet</h3>
<p className="text-sm mb-6 text-center max-w-md">
Create your first project to start organizing your tasks and sprints.
</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create First Project
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{projectsWithStats.map((project) => (
<Card
key={project.id}
className="bg-slate-900 border-slate-800 hover:border-slate-600 transition-all group overflow-hidden"
>
{/* Color bar */}
<div
className="h-2 w-full"
style={{ backgroundColor: project.color }}
/>
<CardHeader className="pb-3">
{editingProjectId === project.id ? (
<div className="space-y-3">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="bg-slate-800 border-slate-700 text-white"
placeholder="Project name"
autoFocus
/>
<Textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
className="bg-slate-800 border-slate-700 text-white text-sm"
placeholder="Description (optional)"
rows={2}
/>
<div className="flex flex-wrap gap-1">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setEditColor(color)}
className={`w-6 h-6 rounded-full border-2 transition-all ${
editColor === color ? "border-white scale-110" : "border-transparent"
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => saveEdit(project.id)}>
<Check className="w-3 h-3 mr-1" />
Save
</Button>
<Button size="sm" variant="outline" onClick={cancelEdit}>
<X className="w-3 h-3 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div className="flex items-start justify-between">
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => router.push(`/projects/${project.id}`)}
>
<h3 className="font-semibold text-white truncate group-hover:text-blue-400 transition-colors">
{project.name}
</h3>
{project.description && (
<p className="text-sm text-slate-400 mt-1 line-clamp-2">
{project.description}
</p>
)}
</div>
<div className="flex items-center gap-1 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(project)}
className="p-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-blue-400"
title="Edit project"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteConfirmId(project.id)}
className="p-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-red-400"
title="Delete project"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</CardHeader>
<CardContent
className="pt-0 cursor-pointer"
onClick={() => router.push(`/projects/${project.id}`)}
>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="bg-slate-800/50 rounded-lg p-2 text-center">
<p className="text-xl font-bold text-white">{project.taskCount}</p>
<p className="text-xs text-slate-400">Total</p>
</div>
<div className="bg-slate-800/50 rounded-lg p-2 text-center">
<p className="text-xl font-bold text-emerald-400">{project.completedTasks}</p>
<p className="text-xs text-slate-400">Done</p>
</div>
<div className="bg-slate-800/50 rounded-lg p-2 text-center">
<p className="text-xl font-bold text-blue-400">{project.inProgressTasks}</p>
<p className="text-xs text-slate-400">Active</p>
</div>
</div>
{/* Progress bar */}
{project.taskCount > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400">Progress</span>
<span className="text-slate-300">
{Math.round((project.completedTasks / project.taskCount) * 100)}%
</span>
</div>
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all bg-emerald-500"
style={{
width: `${(project.completedTasks / project.taskCount) * 100}%`,
backgroundColor: project.color
}}
/>
</div>
</div>
)}
{/* View link */}
<div className="flex items-center justify-center gap-1 text-xs text-slate-500 group-hover:text-blue-400 transition-colors mt-4 pt-3 border-t border-slate-800">
<LayoutGrid className="w-3 h-3" />
<span>View Details</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Create Project Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-lg">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<label className="text-sm text-slate-400">Project Name *</label>
<Input
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="e.g., Website Redesign"
className="mt-1 bg-slate-800 border-slate-700 text-white"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleCreateProject()
}
}}
/>
</div>
<div>
<label className="text-sm text-slate-400">Description</label>
<Textarea
value={newProjectDescription}
onChange={(e) => setNewProjectDescription(e.target.value)}
placeholder="What is this project about?"
rows={3}
className="mt-1 bg-slate-800 border-slate-700 text-white"
/>
</div>
<div>
<label className="text-sm text-slate-400 mb-2 block">Color</label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => setNewProjectColor(color)}
className={`w-8 h-8 rounded-full border-2 transition-all ${
newProjectColor === color
? "border-white scale-110 ring-2 ring-white/20"
: "border-transparent hover:scale-105"
}`}
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateProject} disabled={isCreating || !newProjectName.trim()}>
{isCreating ? "Creating..." : "Create Project"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-md">
<DialogHeader>
<DialogTitle>Delete Project?</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-slate-300">
Are you sure you want to delete this project? This action cannot be undone.
</p>
<p className="text-slate-400 text-sm mt-2">
Tasks in this project will remain but will no longer be associated with a project.
</p>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirmId && handleDeleteProject(deleteConfirmId)}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-white ring-offset-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }