1022 lines
37 KiB
TypeScript
1022 lines
37 KiB
TypeScript
"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<TaskType, string> = {
|
|
idea: "bg-purple-500",
|
|
task: "bg-blue-500",
|
|
bug: "bg-red-500",
|
|
research: "bg-green-500",
|
|
plan: "bg-amber-500",
|
|
}
|
|
|
|
const typeLabels: Record<TaskType, string> = {
|
|
idea: "💡 Idea",
|
|
task: "📋 Task",
|
|
bug: "🐛 Bug",
|
|
research: "🔬 Research",
|
|
plan: "📐 Plan",
|
|
}
|
|
|
|
const priorityColors: Record<Priority, string> = {
|
|
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<TaskAttachment>
|
|
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<TaskComment>
|
|
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<CommentAuthor>
|
|
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 (
|
|
<img
|
|
src={displayUrl}
|
|
alt={name || "User avatar"}
|
|
className={`${sizeClass} rounded-full border border-slate-700 object-cover bg-slate-800`}
|
|
title={title || name || "User"}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const readFileAsDataUrl = (file: File) =>
|
|
new Promise<string>((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<Task | null>(null)
|
|
const [newComment, setNewComment] = useState("")
|
|
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [saveSuccess, setSaveSuccess] = useState(false)
|
|
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
|
const [openReplyEditors, setOpenReplyEditors] = useState<Record<string, boolean>>({})
|
|
const [authReady, setAuthReady] = useState(false)
|
|
const [users, setUsers] = useState<AssignableUser[]>([])
|
|
|
|
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<Partial<AssignableUser>>) : []
|
|
setUsers(
|
|
nextUsers
|
|
.filter((entry): entry is Partial<AssignableUser> & { 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<string, number>()
|
|
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<string, AssignableUser>()
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div key={comment.id} className="space-y-2" style={{ marginLeft: depth * 20 }}>
|
|
<div className={`p-3 rounded-lg border ${isAssistant ? "bg-blue-900/20 border-blue-900/40" : "bg-slate-800/60 border-slate-800"}`}>
|
|
<div className="flex items-center justify-between gap-2 mb-1">
|
|
<div className="flex items-center gap-2">
|
|
{isAssistant ? (
|
|
<span className="w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-medium bg-blue-600 text-white">
|
|
AI
|
|
</span>
|
|
) : (
|
|
<AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={author.id} />
|
|
)}
|
|
<span className="text-sm text-slate-300 font-medium">{displayName}</span>
|
|
<span className="text-xs text-slate-500">{new Date(comment.createdAt).toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpenReplyEditors((prev) => ({ ...prev, [comment.id]: !prev[comment.id] }))}
|
|
className="text-xs text-blue-300 hover:text-blue-200"
|
|
>
|
|
Reply
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteComment(comment.id)}
|
|
className="text-slate-600 hover:text-red-400"
|
|
title="Delete comment"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-slate-200 whitespace-pre-wrap">{comment.text}</p>
|
|
</div>
|
|
|
|
{isReplying && (
|
|
<div className="flex gap-2" style={{ marginLeft: 20 }}>
|
|
<Textarea
|
|
value={replyDraft}
|
|
onChange={(event) => setReplyDrafts((prev) => ({ ...prev, [comment.id]: event.target.value }))}
|
|
placeholder="Write a reply..."
|
|
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
|
rows={2}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && event.metaKey) {
|
|
handleAddReply(comment.id)
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex flex-col gap-2 justify-end">
|
|
<Button onClick={() => handleAddReply(comment.id)} size="sm">Reply</Button>
|
|
<Button onClick={() => setOpenReplyEditors((prev) => ({ ...prev, [comment.id]: false }))} variant="ghost" size="sm">Cancel</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{replies.length > 0 && <div className="space-y-2">{renderThread(replies, depth + 1)}</div>}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
if (!taskId) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
|
|
<p className="text-slate-400">Invalid task URL.</p>
|
|
<Button className="mt-4" onClick={() => router.push("/")}>Back to Board</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!authReady) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 p-6 flex items-center justify-center">
|
|
<p className="text-slate-400">Checking session...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!selectedTask && !isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
|
|
<h1 className="text-xl font-semibold text-white">Task not found</h1>
|
|
<p className="text-slate-400 mt-2">This task may have been deleted.</p>
|
|
<Button className="mt-4" onClick={() => router.push("/")}>Back to Board</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!editedTask) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 p-6">
|
|
<p className="text-slate-400">Loading task...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
|
<div className="max-w-4xl mx-auto p-4 md:p-6">
|
|
<div className="flex items-center justify-between gap-3 mb-4">
|
|
<Button variant="ghost" onClick={() => router.push("/")}>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Back to Board
|
|
</Button>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-500">Task URL: /tasks/{editedTask.id}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push("/settings")}
|
|
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
|
|
>
|
|
Settings
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleLogout}
|
|
className="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<AvatarCircle name={currentUser.name} avatarUrl={currentUser.avatarUrl} seed={currentUser.id} sizeClass="h-5 w-5" />
|
|
Logout {currentUser.name}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border border-slate-800 rounded-xl bg-slate-900/60 p-4 md:p-6">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Badge className={`${typeColors[editedTask.type]} text-white border-0`}>
|
|
{typeLabels[editedTask.type]}
|
|
</Badge>
|
|
<Badge variant="outline" className={priorityColors[editedTask.priority]}>
|
|
{editedTask.priority}
|
|
</Badge>
|
|
</div>
|
|
|
|
<input
|
|
type="text"
|
|
value={editedTask.title}
|
|
onChange={(event) => 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"
|
|
/>
|
|
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-400">
|
|
<span className="inline-flex items-center gap-2">
|
|
<AvatarCircle
|
|
name={editedTask.createdByName || "Unknown"}
|
|
avatarUrl={editedTask.createdByAvatarUrl || resolveAssignee(editedTask.createdById)?.avatarUrl}
|
|
seed={editedTask.createdById}
|
|
sizeClass="h-6 w-6"
|
|
/>
|
|
Created by {editedTask.createdByName || "Unknown"}
|
|
</span>
|
|
<span className="inline-flex items-center gap-2">
|
|
<AvatarCircle
|
|
name={editedTask.updatedByName || "Unknown"}
|
|
avatarUrl={editedTask.updatedByAvatarUrl || resolveAssignee(editedTask.updatedById)?.avatarUrl}
|
|
seed={editedTask.updatedById}
|
|
sizeClass="h-6 w-6"
|
|
/>
|
|
Last updated by {editedTask.updatedByName || "Unknown"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2">
|
|
<span className="text-xs text-slate-500 mr-2">Assignee</span>
|
|
<span className="inline-flex items-center gap-2 text-xs text-slate-300">
|
|
<AvatarCircle
|
|
name={editedTask.assigneeName || "Unassigned"}
|
|
avatarUrl={resolveAssignee(editedTask.assigneeId)?.avatarUrl || editedTask.assigneeAvatarUrl}
|
|
seed={editedTask.assigneeId}
|
|
sizeClass="h-6 w-6"
|
|
/>
|
|
{editedTask.assigneeName || "Unassigned"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-6 py-4">
|
|
<div>
|
|
<Label className="text-slate-400">Description</Label>
|
|
<textarea
|
|
value={editedTask.description || ""}
|
|
onChange={(event) => setEditedTask({ ...editedTask, description: event.target.value })}
|
|
rows={3}
|
|
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-slate-400">Priority</Label>
|
|
<div className="flex gap-2 mt-2">
|
|
{(["low", "medium", "high", "urgent"] as Priority[]).map((priority) => (
|
|
<button
|
|
key={priority}
|
|
onClick={() => setEditedTask({ ...editedTask, priority })}
|
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors capitalize ${
|
|
editedTask.priority === priority
|
|
? priority === "urgent"
|
|
? "bg-red-600 text-white"
|
|
: priority === "high"
|
|
? "bg-orange-600 text-white"
|
|
: priority === "medium"
|
|
? "bg-blue-600 text-white"
|
|
: "bg-slate-600 text-white"
|
|
: "bg-slate-800 text-slate-400 hover:bg-slate-700"
|
|
}`}
|
|
>
|
|
{priority}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-slate-400">Status</Label>
|
|
<select
|
|
value={editedTask.status}
|
|
onChange={(event) => setEditedTask({ ...editedTask, status: event.target.value as TaskStatus })}
|
|
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
{allStatuses.map((status) => (
|
|
<option key={status} value={status}>{status.replace("-", " ").toUpperCase()}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-slate-400">Sprint</Label>
|
|
<select
|
|
value={editedTask.sprintId || ""}
|
|
onChange={(event) => setEditedTask({ ...editedTask, sprintId: event.target.value || undefined })}
|
|
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="">No Sprint</option>
|
|
{sortedSprints.map((sprint) => (
|
|
<option key={sprint.id} value={sprint.id}>
|
|
{sprint.name} ({parseSprintStart(sprint.startDate).toLocaleDateString()})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-slate-400">Assignee</Label>
|
|
<select
|
|
value={editedTask.assigneeId || ""}
|
|
onChange={(event) => setEditedTaskAssignee(event.target.value)}
|
|
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="">Unassigned</option>
|
|
{assignableUsers.map((user) => (
|
|
<option key={user.id} value={user.id}>
|
|
{user.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-slate-400">Labels</Label>
|
|
<div className="mt-2 space-y-2">
|
|
{editedTaskTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{editedTaskTags.map((label) => (
|
|
<Badge
|
|
key={label}
|
|
variant="secondary"
|
|
className="bg-slate-800 text-slate-300 gap-1"
|
|
>
|
|
{label}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setEditedTask({ ...editedTask, tags: removeLabel(editedTaskTags, label) })
|
|
}
|
|
className="text-slate-500 hover:text-slate-200"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
list="edit-task-label-suggestions"
|
|
value={editedTaskLabelInput}
|
|
onChange={(event) => setEditedTaskLabelInput(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === ",") {
|
|
event.preventDefault()
|
|
setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, editedTaskLabelInput) })
|
|
setEditedTaskLabelInput("")
|
|
}
|
|
}}
|
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
|
placeholder="Type a label and press Enter"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, editedTaskLabelInput) })
|
|
setEditedTaskLabelInput("")
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
<datalist id="edit-task-label-suggestions">
|
|
{allLabels.map((label) => (
|
|
<option key={label} value={label} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-slate-400">Attachments</Label>
|
|
<div className="mt-2 space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<label
|
|
htmlFor="task-attachment-upload"
|
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-md border border-slate-700 bg-slate-800 text-sm text-slate-200 hover:border-slate-500 cursor-pointer"
|
|
>
|
|
<Paperclip className="w-3.5 h-3.5" />
|
|
Add Files
|
|
</label>
|
|
<input
|
|
id="task-attachment-upload"
|
|
type="file"
|
|
multiple
|
|
onChange={handleAttachmentUpload}
|
|
className="hidden"
|
|
/>
|
|
<span className="text-xs text-slate-500">
|
|
{editedTaskAttachments.length} file{editedTaskAttachments.length === 1 ? "" : "s"}
|
|
</span>
|
|
</div>
|
|
|
|
{editedTaskAttachments.length === 0 ? (
|
|
<p className="text-sm text-slate-500">No attachments yet.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{editedTaskAttachments.map((attachment) => (
|
|
<div
|
|
key={attachment.id}
|
|
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
|
|
>
|
|
<div className="min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => openAttachment(attachment)}
|
|
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
|
|
>
|
|
{attachment.name}
|
|
</button>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => downloadAttachment(attachment)}
|
|
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
|
|
title="Download attachment"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setEditedTask({
|
|
...editedTask,
|
|
attachments: editedTaskAttachments.filter((item) => item.id !== attachment.id),
|
|
})
|
|
}
|
|
className="p-1.5 rounded text-slate-400 hover:text-red-300 hover:bg-red-900/20"
|
|
title="Remove attachment"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-800 pt-6">
|
|
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
|
<MessageSquare className="w-4 h-4" />
|
|
Comments ({commentCount})
|
|
</h4>
|
|
|
|
<div className="space-y-3 mb-4">
|
|
{commentCount === 0 ? (
|
|
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
|
|
) : (
|
|
renderThread(getComments(editedTask.comments))
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<Textarea
|
|
value={newComment}
|
|
onChange={(event) => setNewComment(event.target.value)}
|
|
placeholder="Add a comment..."
|
|
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
|
rows={2}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && event.metaKey) {
|
|
handleAddComment()
|
|
}
|
|
}}
|
|
/>
|
|
<Button onClick={handleAddComment} className="self-end">
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-slate-800 pt-4 flex justify-between">
|
|
<Button
|
|
variant="ghost"
|
|
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
|
onClick={handleDelete}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Delete Task
|
|
</Button>
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="ghost" onClick={() => router.push("/")}>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
className={saveSuccess ? "bg-green-600 hover:bg-green-700" : undefined}
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : saveSuccess ? (
|
|
<>
|
|
<Check className="w-4 h-4 mr-2" />
|
|
Saved!
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
Save Changes
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|