diff --git a/src/app/page.tsx b/src/app/page.tsx index b16921d..220b35e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,6 +15,7 @@ import { closestCorners, } from "@dnd-kit/core" import { CSS } from "@dnd-kit/utilities" +import { useRouter } from "next/navigation" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" @@ -289,6 +290,7 @@ function KanbanTaskCard({ export default function Home() { console.log('>>> PAGE: Component rendering') + const router = useRouter() const { projects, tasks, @@ -377,6 +379,12 @@ export default function Home() { }) }, [syncFromServer]) + useEffect(() => { + if (selectedTaskId) { + selectTask(null) + } + }, [selectedTaskId, selectTask]) + // Log when tasks change useEffect(() => { console.log('>>> PAGE: tasks changed, new count:', tasks.length) @@ -696,7 +704,7 @@ export default function Home() { key={task.id} task={task} taskTags={getTags(task)} - onOpen={() => selectTask(task.id)} + onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)} onDelete={() => deleteTask(task.id)} /> ))} diff --git a/src/app/tasks/[taskId]/page.tsx b/src/app/tasks/[taskId]/page.tsx new file mode 100644 index 0000000..fca01ef --- /dev/null +++ b/src/app/tasks/[taskId]/page.tsx @@ -0,0 +1,548 @@ +"use client" + +import { useEffect, useMemo, useState, type ChangeEvent } from "react" +import { useParams, useRouter } from "next/navigation" +import { ArrowLeft, Download, MessageSquare, Paperclip, Trash2, X } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { useTaskStore, type Priority, type Task, type TaskAttachment, type TaskStatus, type TaskType } from "@/stores/useTaskStore" + +const typeColors: Record = { + idea: "bg-purple-500", + task: "bg-blue-500", + bug: "bg-red-500", + research: "bg-green-500", + plan: "bg-amber-500", +} + +const typeLabels: Record = { + idea: "๐Ÿ’ก Idea", + task: "๐Ÿ“‹ Task", + bug: "๐Ÿ› Bug", + research: "๐Ÿ”ฌ Research", + plan: "๐Ÿ“ Plan", +} + +const priorityColors: Record = { + low: "text-slate-400", + medium: "text-blue-400", + high: "text-orange-400", + urgent: "text-red-400", +} + +const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"] + +const getTags = (taskLike: { tags?: unknown }) => { + if (!Array.isArray(taskLike.tags)) return [] as string[] + return taskLike.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) +} + +const getAttachments = (taskLike: { attachments?: unknown }) => { + if (!Array.isArray(taskLike.attachments)) return [] as TaskAttachment[] + return taskLike.attachments.filter((attachment): attachment is TaskAttachment => { + if (!attachment || typeof attachment !== "object") return false + const candidate = attachment as Partial + return typeof candidate.id === "string" + && typeof candidate.name === "string" + && typeof candidate.dataUrl === "string" + && typeof candidate.uploadedAt === "string" + && typeof candidate.type === "string" + && typeof candidate.size === "number" + }) +} + +const toLabel = (raw: string) => raw.trim().replace(/^#/, "") + +const addUniqueLabel = (existing: string[], raw: string) => { + const nextLabel = toLabel(raw) + if (!nextLabel) return existing + const alreadyExists = existing.some((label) => label.toLowerCase() === nextLabel.toLowerCase()) + return alreadyExists ? existing : [...existing, nextLabel] +} + +const removeLabel = (existing: string[], labelToRemove: string) => + existing.filter((label) => label.toLowerCase() !== labelToRemove.toLowerCase()) + +const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B" + const units = ["B", "KB", "MB", "GB"] + const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) + const value = bytes / 1024 ** unitIndex + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}` +} + +const readFileAsDataUrl = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || "")) + reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`)) + reader.readAsDataURL(file) + }) + +export default function TaskDetailPage() { + const params = useParams<{ taskId: string }>() + const router = useRouter() + const routeTaskId = Array.isArray(params.taskId) ? params.taskId[0] : params.taskId + const taskId = decodeURIComponent(routeTaskId || "") + + const { + tasks, + sprints, + updateTask, + deleteTask, + syncFromServer, + isLoading, + } = useTaskStore() + + const selectedTask = tasks.find((task) => task.id === taskId) + const [editedTask, setEditedTask] = useState(null) + const [newComment, setNewComment] = useState("") + const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + syncFromServer() + }, [syncFromServer]) + + useEffect(() => { + if (selectedTask) { + setEditedTask({ + ...selectedTask, + tags: getTags(selectedTask), + attachments: getAttachments(selectedTask), + }) + setEditedTaskLabelInput("") + } + }, [selectedTask]) + + const editedTaskTags = editedTask ? getTags(editedTask) : [] + const editedTaskAttachments = editedTask ? getAttachments(editedTask) : [] + + const allLabels = useMemo(() => { + const labels = new Map() + tasks.forEach((task) => { + getTags(task).forEach((label) => { + labels.set(label, (labels.get(label) || 0) + 1) + }) + }) + return Array.from(labels.keys()) + }, [tasks]) + + const handleAttachmentUpload = async (event: ChangeEvent) => { + const files = Array.from(event.target.files || []) + if (files.length === 0) return + + try { + const uploadedAt = new Date().toISOString() + const attachments = await Promise.all( + files.map(async (file) => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: file.name, + type: file.type || "application/octet-stream", + size: file.size, + dataUrl: await readFileAsDataUrl(file), + uploadedAt, + })) + ) + + setEditedTask((prev) => { + if (!prev) return prev + return { + ...prev, + attachments: [...getAttachments(prev), ...attachments], + } + }) + } catch (error) { + console.error("Failed to upload attachment:", error) + } finally { + event.target.value = "" + } + } + + const handleAddComment = () => { + if (!editedTask || !newComment.trim()) return + + setEditedTask({ + ...editedTask, + comments: [ + ...(editedTask.comments || []), + { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + text: newComment.trim(), + createdAt: new Date().toISOString(), + author: "user", + }, + ], + }) + setNewComment("") + } + + const handleSave = () => { + if (!editedTask) return + setIsSaving(true) + updateTask(editedTask.id, editedTask) + setIsSaving(false) + } + + const handleDelete = () => { + if (!editedTask) return + if (!window.confirm("Delete this task?")) return + deleteTask(editedTask.id) + router.push("/") + } + + if (!taskId) { + return ( +
+

Invalid task URL.

+ +
+ ) + } + + if (!selectedTask && !isLoading) { + return ( +
+

Task not found

+

This task may have been deleted.

+ +
+ ) + } + + if (!editedTask) { + return ( +
+

Loading task...

+
+ ) + } + + return ( +
+
+
+ + Task URL: /tasks/{editedTask.id} +
+ +
+
+ + {typeLabels[editedTask.type]} + + + {editedTask.priority} + +
+ + setEditedTask({ ...editedTask, title: event.target.value })} + className="w-full text-xl font-semibold bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white" + /> + +
+
+ +