added project
Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
parent
60aed51f3c
commit
2797256bd7
@ -63,9 +63,8 @@ const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
||||
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;
|
||||
|
||||
// Optimized field selection - only fetch fields needed for board display
|
||||
// Full task details (description, comments, attachments) fetched lazily
|
||||
const TASK_FIELDS_LIGHT = [
|
||||
// Optimized field selection - fetch all fields needed for board and detail display
|
||||
const TASK_FIELDS = [
|
||||
"id",
|
||||
"title",
|
||||
"type",
|
||||
@ -80,6 +79,9 @@ const TASK_FIELDS_LIGHT = [
|
||||
"assignee_id",
|
||||
"due_date",
|
||||
"tags",
|
||||
"comments",
|
||||
"attachments",
|
||||
"description",
|
||||
];
|
||||
|
||||
class HttpError extends Error {
|
||||
@ -287,7 +289,7 @@ export async function GET() {
|
||||
] = await Promise.all([
|
||||
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("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"),
|
||||
]);
|
||||
|
||||
|
||||
760
src/app/projects/[id]/page.tsx
Normal file
760
src/app/projects/[id]/page.tsx
Normal 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're looking for doesn'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 "Add Task"</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
478
src/app/projects/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 }
|
||||
Loading…
Reference in New Issue
Block a user