"use client" import { useEffect, useMemo, useState, type ChangeEvent } from "react" import { useParams, useRouter } from "next/navigation" import { ArrowLeft, Check, Download, Loader2, MessageSquare, Paperclip, Save, 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 { generateAvatarDataUrl } from "@/lib/avatar" import { blobFromDataUrl, coerceDataUrlMimeType, inferAttachmentMimeType, isMarkdownAttachment, isTextPreviewAttachment, markdownPreviewObjectUrl, textPreviewObjectUrl, } from "@/lib/attachments" import { parseSprintStart } from "@/lib/utils" import { useTaskStore, type Comment as TaskComment, type CommentAuthor, type Priority, type Task, type TaskAttachment, type TaskStatus, type TaskType, type UserProfile, } from "@/stores/useTaskStore" import { toast } from "sonner" interface AssignableUser { id: string name: string email?: string avatarUrl?: string } 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 getComments = (value: unknown): TaskComment[] => { if (!Array.isArray(value)) return [] const normalized: TaskComment[] = [] for (const entry of value) { if (!entry || typeof entry !== "object") continue const comment = entry as Partial if (typeof comment.id !== "string" || typeof comment.text !== "string") continue normalized.push({ id: comment.id, text: comment.text, createdAt: typeof comment.createdAt === "string" ? comment.createdAt : new Date().toISOString(), author: getCommentAuthor(comment.author), replies: getComments(comment.replies), }) } return normalized } const getCommentAuthor = (value: unknown): CommentAuthor => { if (value === "assistant") { return { id: "assistant", name: "Assistant", type: "assistant" } } if (value === "user") { return { id: "legacy-user", name: "User", type: "human" } } if (!value || typeof value !== "object") { return { id: "legacy-user", name: "User", type: "human" } } const candidate = value as Partial const type = candidate.type === "assistant" || candidate.id === "assistant" ? "assistant" : "human" return { id: typeof candidate.id === "string" && candidate.id ? candidate.id : type === "assistant" ? "assistant" : "legacy-user", name: typeof candidate.name === "string" && candidate.name ? candidate.name : type === "assistant" ? "Assistant" : "User", email: typeof candidate.email === "string" && candidate.email ? candidate.email : undefined, avatarUrl: typeof candidate.avatarUrl === "string" && candidate.avatarUrl ? candidate.avatarUrl : undefined, type, } } const profileToAuthor = (profile: UserProfile): CommentAuthor => ({ id: profile.id, name: profile.name, email: profile.email, avatarUrl: profile.avatarUrl, type: "human", }) const buildComment = (text: string, author: CommentAuthor): TaskComment => ({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, text, createdAt: new Date().toISOString(), author, replies: [], }) const addReplyToThread = (comments: TaskComment[], parentId: string, reply: TaskComment): TaskComment[] => comments.map((comment) => { if (comment.id === parentId) { return { ...comment, replies: [...getComments(comment.replies), reply], } } return { ...comment, replies: addReplyToThread(getComments(comment.replies), parentId, reply), } }) const removeCommentFromThread = (comments: TaskComment[], targetId: string): TaskComment[] => comments .filter((comment) => comment.id !== targetId) .map((comment) => ({ ...comment, replies: removeCommentFromThread(getComments(comment.replies), targetId), })) const countThreadComments = (comments: TaskComment[]): number => comments.reduce((total, comment) => total + 1 + countThreadComments(getComments(comment.replies)), 0) 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]}` } function AvatarCircle({ name, avatarUrl, seed, sizeClass = "h-7 w-7", title, }: { name?: string avatarUrl?: string seed?: string sizeClass?: string title?: string }) { const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "default", name || "User") return ( {name ) } 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, currentUser, updateTask, deleteTask, setCurrentUser, 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) const [saveSuccess, setSaveSuccess] = useState(false) const [replyDrafts, setReplyDrafts] = useState>({}) const [openReplyEditors, setOpenReplyEditors] = useState>({}) const [authReady, setAuthReady] = useState(false) const [users, setUsers] = useState([]) 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 } const data = await res.json() if (!isMounted) return setCurrentUser({ id: data.user.id, name: data.user.name, email: data.user.email, avatarUrl: data.user.avatarUrl, }) setAuthReady(true) } catch { if (isMounted) router.replace("/login") } } loadSession() return () => { isMounted = false } }, [router, setCurrentUser]) useEffect(() => { if (!authReady) return syncFromServer() }, [authReady, syncFromServer]) useEffect(() => { if (!authReady) return let isMounted = true const loadUsers = async () => { try { const res = await fetch("/api/auth/users", { cache: "no-store" }) if (!res.ok) return const data = await res.json() if (!isMounted) return const nextUsers = Array.isArray(data.users) ? (data.users as Array>) : [] setUsers( nextUsers .filter((entry): entry is Partial & { id: string; name: string } => !!entry && typeof entry.id === "string" && typeof entry.name === "string" ) .map((entry) => ({ id: entry.id, name: entry.name, email: entry.email, avatarUrl: entry.avatarUrl })) ) } catch { // ignore } } loadUsers() return () => { isMounted = false } }, [authReady]) useEffect(() => { if (selectedTask) { setEditedTask({ ...selectedTask, tags: getTags(selectedTask), comments: getComments(selectedTask.comments), attachments: getAttachments(selectedTask), }) setEditedTaskLabelInput("") } }, [selectedTask]) const editedTaskTags = editedTask ? getTags(editedTask) : [] const editedTaskAttachments = editedTask ? getAttachments(editedTask) : [] const commentCount = editedTask ? countThreadComments(getComments(editedTask.comments)) : 0 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 assignableUsers = useMemo(() => { const byId = new Map() users.forEach((user) => { if (user.id) byId.set(user.id, user) }) if (currentUser.id) { byId.set(currentUser.id, { id: currentUser.id, name: currentUser.name, email: currentUser.email, avatarUrl: currentUser.avatarUrl, }) } return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)) }, [users, currentUser]) const sortedSprints = useMemo( () => sprints .slice() .sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime()), [sprints] ) 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) => { const type = inferAttachmentMimeType(file.name, file.type) const rawDataUrl = await readFileAsDataUrl(file) return { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: file.name, type, size: file.size, dataUrl: coerceDataUrlMimeType(rawDataUrl, type), 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 const actor = profileToAuthor(currentUser) setEditedTask({ ...editedTask, comments: [...getComments(editedTask.comments), buildComment(newComment.trim(), actor)], }) setNewComment("") } const handleAddReply = (parentId: string) => { if (!editedTask) return const text = replyDrafts[parentId]?.trim() if (!text) return const actor = profileToAuthor(currentUser) setEditedTask({ ...editedTask, comments: addReplyToThread(getComments(editedTask.comments), parentId, buildComment(text, actor)), }) setReplyDrafts((prev) => ({ ...prev, [parentId]: "" })) setOpenReplyEditors((prev) => ({ ...prev, [parentId]: false })) } const handleDeleteComment = (commentId: string) => { if (!editedTask) return setEditedTask({ ...editedTask, comments: removeCommentFromThread(getComments(editedTask.comments), commentId), }) } const resolveAssignee = (assigneeId: string | undefined) => { if (!assigneeId) return null return assignableUsers.find((user) => user.id === assigneeId) || null } const setEditedTaskAssignee = (assigneeId: string) => { if (!editedTask) return if (!assigneeId) { setEditedTask({ ...editedTask, assigneeId: undefined, assigneeName: undefined, assigneeEmail: undefined, assigneeAvatarUrl: undefined, }) return } const assignee = resolveAssignee(assigneeId) setEditedTask({ ...editedTask, assigneeId, assigneeName: assignee?.name || editedTask.assigneeName, assigneeEmail: assignee?.email, assigneeAvatarUrl: assignee?.avatarUrl, }) } const handleSave = async () => { if (!editedTask) return setIsSaving(true) setSaveSuccess(false) try { const success = await updateTask(editedTask.id, { ...editedTask, comments: getComments(editedTask.comments), }) if (success) { setSaveSuccess(true) toast.success("Task saved successfully", { description: "Your changes have been saved to the server.", duration: 3000, }) // Reset success state after 2 seconds setTimeout(() => setSaveSuccess(false), 2000) } else { toast.error("Failed to save task", { description: "Changes were saved locally but could not sync to the server. Please try again.", duration: 5000, }) } } catch (error) { toast.error("Error saving task", { description: error instanceof Error ? error.message : "An unexpected error occurred.", duration: 5000, }) } finally { setIsSaving(false) } } const openAttachment = async (attachment: TaskAttachment) => { try { const mimeType = inferAttachmentMimeType(attachment.name, attachment.type) const objectUrl = isMarkdownAttachment(attachment.name, mimeType) ? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl) : isTextPreviewAttachment(attachment.name, mimeType) ? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType) : URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType)) window.open(objectUrl, "_blank", "noopener,noreferrer") window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) } catch (error) { console.error("Failed to open attachment:", error) } } const downloadAttachment = async (attachment: TaskAttachment) => { try { const mimeType = inferAttachmentMimeType(attachment.name, attachment.type) const blob = await blobFromDataUrl(attachment.dataUrl, mimeType) const objectUrl = URL.createObjectURL(blob) const link = document.createElement("a") link.href = objectUrl link.download = attachment.name document.body.appendChild(link) link.click() link.remove() window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) } catch (error) { console.error("Failed to download attachment:", error) } } const handleDelete = () => { if (!editedTask) return if (!window.confirm("Delete this task?")) return deleteTask(editedTask.id) router.push("/") } const handleLogout = async () => { try { await fetch("/api/auth/logout", { method: "POST" }) } finally { setAuthReady(false) router.replace("/login") } } const renderThread = (comments: TaskComment[], depth = 0) => comments.map((comment) => { const replies = getComments(comment.replies) const isReplying = !!openReplyEditors[comment.id] const replyDraft = replyDrafts[comment.id] || "" const author = getCommentAuthor(comment.author) const isAssistant = author.type === "assistant" const displayName = isAssistant ? "Assistant" : author.id === currentUser.id ? "You" : author.name const resolvedAuthorAvatar = author.avatarUrl || resolveAssignee(author.id)?.avatarUrl return (
{isAssistant ? ( AI ) : ( )} {displayName} {new Date(comment.createdAt).toLocaleString()}

{comment.text}

{isReplying && (