1907 lines
74 KiB
TypeScript
1907 lines
74 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useMemo, type ChangeEvent, type ReactNode, Suspense } from "react"
|
|
import dynamic from "next/dynamic"
|
|
import { useDebounce } from "@/hooks/useDebounce"
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
DragOverEvent,
|
|
DragStartEvent,
|
|
PointerSensor,
|
|
useDraggable,
|
|
useDroppable,
|
|
useSensor,
|
|
useSensors,
|
|
closestCorners,
|
|
} from "@dnd-kit/core"
|
|
import { CSS } from "@dnd-kit/utilities"
|
|
import { useRouter, usePathname } from "next/navigation"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent } from "@/components/ui/card"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Label } from "@/components/ui/label"
|
|
import { isSprintInProgress, parseSprintEnd, parseSprintStart } from "@/lib/utils"
|
|
import { generateAvatarDataUrl } from "@/lib/avatar"
|
|
import {
|
|
blobFromDataUrl,
|
|
coerceDataUrlMimeType,
|
|
inferAttachmentMimeType,
|
|
isMarkdownAttachment,
|
|
isTextPreviewAttachment,
|
|
markdownPreviewObjectUrl,
|
|
textPreviewObjectUrl,
|
|
} from "@/lib/attachments"
|
|
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
|
|
import { BacklogSkeleton, SearchSkeleton } from "@/components/LoadingSkeletons"
|
|
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive, Folder } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
|
|
// Dynamic imports for heavy view components - only load when needed
|
|
const BacklogView = dynamic(() => import("@/components/BacklogView").then(mod => mod.BacklogView), {
|
|
loading: () => <BacklogSkeleton />,
|
|
ssr: false,
|
|
})
|
|
|
|
const SearchView = dynamic(() => import("@/components/SearchView").then(mod => mod.SearchView), {
|
|
loading: () => <SearchSkeleton />,
|
|
ssr: false,
|
|
})
|
|
|
|
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"]
|
|
|
|
function AvatarCircle({
|
|
name,
|
|
avatarUrl,
|
|
seed,
|
|
sizeClass = "h-6 w-6",
|
|
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"}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// Sprint board columns mapped to workflow statuses
|
|
const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [
|
|
{
|
|
key: "todo",
|
|
label: "To Do",
|
|
statuses: ["open", "todo"]
|
|
},
|
|
{
|
|
key: "inprogress",
|
|
label: "In Progress",
|
|
statuses: ["blocked", "in-progress", "review", "validate"]
|
|
},
|
|
{
|
|
key: "done",
|
|
label: "Done",
|
|
statuses: ["archived", "canceled", "done"]
|
|
},
|
|
]
|
|
|
|
const sprintColumnDropStatus: Record<string, TaskStatus> = {
|
|
todo: "open",
|
|
inprogress: "in-progress",
|
|
done: "done",
|
|
}
|
|
|
|
const formatStatusLabel = (status: TaskStatus) =>
|
|
status === "todo"
|
|
? "To Do"
|
|
:
|
|
status
|
|
.split("-")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ")
|
|
|
|
const formatSprintDisplayDate = (value: string, boundary: "start" | "end" = "start") => {
|
|
const parsed = boundary === "end" ? parseSprintEnd(value) : parseSprintStart(value)
|
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString()
|
|
}
|
|
|
|
function KanbanStatusDropTarget({
|
|
status,
|
|
count,
|
|
expanded = false,
|
|
}: {
|
|
status: TaskStatus
|
|
count: number
|
|
expanded?: boolean
|
|
}) {
|
|
const { isOver, setNodeRef } = useDroppable({ id: `kanban-status-${status}` })
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
className={`rounded-md border transition-colors ${
|
|
expanded ? "px-3 py-3 text-xs" : "px-2 py-1 text-[11px]"
|
|
} ${
|
|
isOver
|
|
? "border-blue-400/70 bg-blue-500/20 text-blue-200"
|
|
: "border-slate-700 text-slate-400 bg-slate-900/40"
|
|
}`}
|
|
title={`Drop to set status: ${formatStatusLabel(status)}`}
|
|
>
|
|
{formatStatusLabel(status)} ({count})
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KanbanDropColumn({
|
|
id,
|
|
label,
|
|
count,
|
|
statusTargets,
|
|
isDragging,
|
|
isActiveDropColumn,
|
|
children,
|
|
}: {
|
|
id: string
|
|
label: string
|
|
count: number
|
|
statusTargets: Array<{ status: TaskStatus; count: number }>
|
|
isDragging: boolean
|
|
isActiveDropColumn: boolean
|
|
children: ReactNode
|
|
}) {
|
|
const { isOver, setNodeRef } = useDroppable({ id })
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium text-slate-400">{label}</h3>
|
|
<Badge variant="secondary" className="bg-slate-800 text-slate-400">
|
|
{count}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{statusTargets.map((target) => (
|
|
<KanbanStatusDropTarget
|
|
key={target.status}
|
|
status={target.status}
|
|
count={target.count}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div
|
|
ref={setNodeRef}
|
|
className={`space-y-3 min-h-32 rounded-lg p-2 transition-colors ${
|
|
isOver ? "bg-blue-500/10 ring-1 ring-blue-500/50" : ""
|
|
}`}
|
|
>
|
|
{isDragging && isActiveDropColumn ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-slate-400 px-1">Drop into an exact status:</p>
|
|
{statusTargets.map((target) => (
|
|
<KanbanStatusDropTarget
|
|
key={`expanded-${target.status}`}
|
|
status={target.status}
|
|
count={target.count}
|
|
expanded
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
children
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KanbanTaskCard({
|
|
task,
|
|
taskTags,
|
|
assigneeAvatarUrl,
|
|
onOpen,
|
|
onDelete,
|
|
}: {
|
|
task: Task
|
|
taskTags: string[]
|
|
assigneeAvatarUrl?: string
|
|
onOpen: () => void
|
|
onDelete: () => void
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
|
id: task.id,
|
|
})
|
|
|
|
const style = {
|
|
transform: CSS.Translate.toString(transform),
|
|
opacity: isDragging ? 0.6 : 1,
|
|
}
|
|
const attachmentCount = task.attachments?.length || 0
|
|
|
|
return (
|
|
<Card
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
|
|
onClick={onOpen}
|
|
>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="h-7 w-6 shrink-0 rounded border border-slate-700/80 bg-slate-800/70 flex items-center justify-center text-slate-400 hover:text-slate-200 cursor-grab active:cursor-grabbing"
|
|
title="Drag task"
|
|
aria-label="Drag task"
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical className="w-3.5 h-3.5" />
|
|
</button>
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-xs ${typeColors[task.type]} text-white border-0`}
|
|
>
|
|
{typeLabels[task.type]}
|
|
</Badge>
|
|
</div>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onDelete()
|
|
}}
|
|
className="p-1 hover:bg-slate-800 rounded"
|
|
>
|
|
<Trash2 className="w-3 h-3 text-slate-400" />
|
|
</button>
|
|
</div>
|
|
|
|
<h4 className="font-medium text-white mb-1">{task.title}</h4>
|
|
{task.description && (
|
|
<p className="text-sm text-slate-400 line-clamp-2 mb-3">
|
|
{task.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="bg-slate-800 text-slate-300 text-[10px] uppercase tracking-wide">
|
|
{formatStatusLabel(task.status)}
|
|
</Badge>
|
|
<span className={priorityColors[task.priority]}>
|
|
{task.priority}
|
|
</span>
|
|
{task.comments && task.comments.length > 0 && (
|
|
<span className="flex items-center gap-1 text-slate-500">
|
|
<MessageSquare className="w-3 h-3" />
|
|
{task.comments.length}
|
|
</span>
|
|
)}
|
|
{attachmentCount > 0 && (
|
|
<span className="flex items-center gap-1 text-slate-500">
|
|
<Paperclip className="w-3 h-3" />
|
|
{attachmentCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<AvatarCircle
|
|
name={task.assigneeName || "Unassigned"}
|
|
avatarUrl={assigneeAvatarUrl}
|
|
seed={task.assigneeId}
|
|
title={task.assigneeName ? `Assigned to ${task.assigneeName}` : "Unassigned"}
|
|
/>
|
|
{task.dueDate && (
|
|
<span className="text-slate-500 flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" />
|
|
{new Date(task.dueDate).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{taskTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-3">
|
|
{taskTags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
export default function Home() {
|
|
console.log('>>> PAGE: Component rendering')
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const {
|
|
projects,
|
|
tasks,
|
|
sprints,
|
|
currentUser,
|
|
selectedProjectId,
|
|
selectedTaskId,
|
|
addTask,
|
|
updateTask,
|
|
deleteTask,
|
|
selectTask,
|
|
addComment,
|
|
deleteComment,
|
|
setCurrentUser,
|
|
syncFromServer,
|
|
isLoading,
|
|
updateSprint,
|
|
} = useTaskStore()
|
|
|
|
const [newTaskOpen, setNewTaskOpen] = useState(false)
|
|
const [newTask, setNewTask] = useState<Partial<Task>>({
|
|
title: "",
|
|
description: "",
|
|
type: "task",
|
|
priority: "medium",
|
|
status: "open",
|
|
tags: [],
|
|
})
|
|
const [newComment, setNewComment] = useState("")
|
|
const [viewMode, setViewMode] = useState<'kanban' | 'backlog' | 'search'>('kanban')
|
|
const [editedTask, setEditedTask] = useState<Task | null>(null)
|
|
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
|
|
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
|
|
const [activeKanbanTaskId, setActiveKanbanTaskId] = useState<string | null>(null)
|
|
const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState<string | null>(null)
|
|
const [authReady, setAuthReady] = useState(false)
|
|
const [initialSyncComplete, setInitialSyncComplete] = useState(false)
|
|
const [hasLoadedAllTasks, setHasLoadedAllTasks] = useState(false)
|
|
const [users, setUsers] = useState<AssignableUser[]>([])
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
|
|
|
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 getCommentAuthorId = (value: unknown): string | null =>
|
|
typeof value === "string" && value.trim().length > 0 ? value.trim() : null
|
|
|
|
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<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)
|
|
})
|
|
|
|
const labelUsage = useMemo(() => {
|
|
const counts = new Map<string, number>()
|
|
tasks.forEach((task) => {
|
|
getTags(task).forEach((label) => {
|
|
counts.set(label, (counts.get(label) || 0) + 1)
|
|
})
|
|
})
|
|
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])
|
|
}, [tasks])
|
|
|
|
const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage])
|
|
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 knownUsersById = useMemo(() => {
|
|
const byId = new Map<string, AssignableUser>()
|
|
|
|
const addUser = (id: string | undefined, name: string | undefined, avatarUrl?: string, email?: string) => {
|
|
if (!id || !name) return
|
|
if (id.trim().length === 0 || name.trim().length === 0) return
|
|
byId.set(id, { id, name, avatarUrl, email })
|
|
}
|
|
|
|
assignableUsers.forEach((user) => byId.set(user.id, user))
|
|
tasks.forEach((task) => {
|
|
addUser(task.createdById, task.createdByName, task.createdByAvatarUrl)
|
|
addUser(task.updatedById, task.updatedByName, task.updatedByAvatarUrl)
|
|
addUser(task.assigneeId, task.assigneeName, task.assigneeAvatarUrl, task.assigneeEmail)
|
|
})
|
|
|
|
return byId
|
|
}, [assignableUsers, tasks])
|
|
|
|
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
|
|
let active = true
|
|
setInitialSyncComplete(false)
|
|
|
|
const runInitialSync = async () => {
|
|
await syncFromServer({ scope: 'active-sprint' })
|
|
if (active) {
|
|
setInitialSyncComplete(true)
|
|
setHasLoadedAllTasks(false)
|
|
}
|
|
}
|
|
|
|
void runInitialSync()
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [authReady, syncFromServer])
|
|
|
|
useEffect(() => {
|
|
if (!authReady || hasLoadedAllTasks || viewMode === 'kanban') return
|
|
|
|
let active = true
|
|
const loadFullTaskSet = async () => {
|
|
await syncFromServer({ scope: 'all' })
|
|
if (active) setHasLoadedAllTasks(true)
|
|
}
|
|
|
|
void loadFullTaskSet()
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [authReady, hasLoadedAllTasks, viewMode, 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 (!authReady) return
|
|
setNewTask((prev) => {
|
|
if (prev.assigneeId) return prev
|
|
return {
|
|
...prev,
|
|
assigneeId: currentUser.id,
|
|
assigneeName: currentUser.name,
|
|
assigneeEmail: currentUser.email,
|
|
assigneeAvatarUrl: currentUser.avatarUrl,
|
|
}
|
|
})
|
|
}, [authReady, currentUser.id, currentUser.name, currentUser.email, currentUser.avatarUrl])
|
|
|
|
useEffect(() => {
|
|
if (selectedTaskId) {
|
|
selectTask(null)
|
|
}
|
|
}, [selectedTaskId, selectTask])
|
|
|
|
// Log when tasks change
|
|
useEffect(() => {
|
|
console.log('>>> PAGE: tasks changed, new count:', tasks.length)
|
|
}, [tasks])
|
|
|
|
// Auto-switch to search view when user types in search box
|
|
useEffect(() => {
|
|
if (debouncedSearchQuery.trim() && viewMode !== 'search') {
|
|
setViewMode('search')
|
|
}
|
|
}, [debouncedSearchQuery, viewMode])
|
|
|
|
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
|
|
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
|
const editedTaskAttachments = editedTask ? getAttachments(editedTask) : []
|
|
|
|
useEffect(() => {
|
|
if (selectedTask) {
|
|
setEditedTask({
|
|
...selectedTask,
|
|
tags: getTags(selectedTask),
|
|
attachments: getAttachments(selectedTask),
|
|
})
|
|
setEditedTaskLabelInput("")
|
|
}
|
|
}, [selectedTask])
|
|
|
|
// Get current sprint (across all projects) using local-day boundaries.
|
|
// Prioritize date matching - if today falls within sprint dates, it's current
|
|
const now = new Date()
|
|
const sprintsInProgress = sprints.filter((s) => isSprintInProgress(s.startDate, s.endDate, now))
|
|
|
|
// First: prefer active sprints that are in date range
|
|
// Second: any sprint in date range (not completed)
|
|
// Third: any sprint in date range (even if completed, for edge cases)
|
|
const currentSprint =
|
|
sprintsInProgress.find((s) => s.status === "active") ??
|
|
sprintsInProgress.find((s) => s.status !== "completed") ??
|
|
sprintsInProgress[0] ??
|
|
null
|
|
|
|
// Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
|
|
// Sort by updatedAt descending (latest first)
|
|
const sprintTasks = currentSprint
|
|
? tasks
|
|
.filter((t) => {
|
|
if (t.sprintId !== currentSprint.id) return false
|
|
// Apply search filter
|
|
if (debouncedSearchQuery.trim()) {
|
|
const query = debouncedSearchQuery.toLowerCase()
|
|
const matchesTitle = t.title.toLowerCase().includes(query)
|
|
const matchesDescription = t.description?.toLowerCase().includes(query) ?? false
|
|
return matchesTitle || matchesDescription
|
|
}
|
|
return true
|
|
})
|
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
: []
|
|
|
|
// Auto-rollover: Move incomplete tasks from ended sprints to next sprint
|
|
useEffect(() => {
|
|
if (!authReady || !hasLoadedAllTasks || sprints.length === 0) return
|
|
|
|
const now = new Date()
|
|
const endedSprints = sprints.filter((s) => {
|
|
if (s.status === "completed") return false
|
|
const sprintEnd = parseSprintEnd(s.endDate)
|
|
return sprintEnd < now
|
|
})
|
|
|
|
if (endedSprints.length === 0) return
|
|
|
|
// Find next sprint (earliest start date that's in the future or active)
|
|
const nextSprint = sprints
|
|
.filter((s) => s.status !== "completed" && !endedSprints.find((e) => e.id === s.id))
|
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())[0]
|
|
|
|
if (!nextSprint) return
|
|
|
|
// Process each ended sprint
|
|
endedSprints.forEach((endedSprint) => {
|
|
const incompleteTasks = tasks.filter(
|
|
(t) => t.sprintId === endedSprint.id && t.status !== 'done' && t.status !== 'canceled' && t.status !== 'archived'
|
|
)
|
|
|
|
if (incompleteTasks.length > 0) {
|
|
console.log(`Auto-rolling over ${incompleteTasks.length} tasks from "${endedSprint.name}" to "${nextSprint.name}"`)
|
|
|
|
// Move incomplete tasks to next sprint
|
|
incompleteTasks.forEach((task) => {
|
|
updateTask(task.id, { sprintId: nextSprint.id })
|
|
})
|
|
|
|
// Mark ended sprint as completed
|
|
updateSprint(endedSprint.id, { status: 'completed' })
|
|
} else {
|
|
// No incomplete tasks, just mark as completed
|
|
updateSprint(endedSprint.id, { status: 'completed' })
|
|
}
|
|
})
|
|
}, [authReady, hasLoadedAllTasks, sprints, tasks, updateTask, updateSprint])
|
|
|
|
const activeKanbanTask = activeKanbanTaskId
|
|
? sprintTasks.find((task) => task.id === activeKanbanTaskId)
|
|
: null
|
|
|
|
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 getColumnKeyForStatus = (status: TaskStatus) =>
|
|
sprintColumns.find((column) => column.statuses.includes(status))?.key
|
|
|
|
const kanbanSensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
})
|
|
)
|
|
|
|
const handleKanbanDragStart = (event: DragStartEvent) => {
|
|
setActiveKanbanTaskId(String(event.active.id))
|
|
}
|
|
|
|
const resolveKanbanColumnKey = (overId: string): string | null => {
|
|
if (overId.startsWith("kanban-col-")) {
|
|
return overId.replace("kanban-col-", "")
|
|
}
|
|
if (overId.startsWith("kanban-status-")) {
|
|
const status = overId.replace("kanban-status-", "") as TaskStatus
|
|
return getColumnKeyForStatus(status) || null
|
|
}
|
|
const overTask = sprintTasks.find((task) => task.id === overId)
|
|
if (!overTask) return null
|
|
return getColumnKeyForStatus(overTask.status) || null
|
|
}
|
|
|
|
const handleKanbanDragOver = (event: DragOverEvent) => {
|
|
if (!event.over) {
|
|
setDragOverKanbanColumnKey(null)
|
|
return
|
|
}
|
|
setDragOverKanbanColumnKey(resolveKanbanColumnKey(String(event.over.id)))
|
|
}
|
|
|
|
const handleKanbanDragEnd = (event: DragEndEvent) => {
|
|
const { active, over } = event
|
|
setActiveKanbanTaskId(null)
|
|
setDragOverKanbanColumnKey(null)
|
|
|
|
if (!over) return
|
|
|
|
const taskId = String(active.id)
|
|
const draggedTask = sprintTasks.find((task) => task.id === taskId)
|
|
if (!draggedTask) return
|
|
const destination = String(over.id)
|
|
|
|
const overTask = sprintTasks.find((task) => task.id === destination)
|
|
if (overTask) {
|
|
if (overTask.status !== draggedTask.status) {
|
|
updateTask(taskId, { status: overTask.status })
|
|
}
|
|
return
|
|
}
|
|
|
|
if (destination.startsWith("kanban-status-")) {
|
|
const exactStatus = destination.replace("kanban-status-", "") as TaskStatus
|
|
if (exactStatus !== draggedTask.status) {
|
|
updateTask(taskId, { status: exactStatus })
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!destination.startsWith("kanban-col-")) return
|
|
|
|
const destinationColumnKey = destination.replace("kanban-col-", "")
|
|
const sourceColumnKey = getColumnKeyForStatus(draggedTask.status)
|
|
if (sourceColumnKey === destinationColumnKey) return
|
|
|
|
const newStatus = sprintColumnDropStatus[destinationColumnKey]
|
|
if (!newStatus) return
|
|
|
|
updateTask(taskId, { status: newStatus })
|
|
}
|
|
|
|
const handleKanbanDragCancel = () => {
|
|
setActiveKanbanTaskId(null)
|
|
setDragOverKanbanColumnKey(null)
|
|
}
|
|
|
|
const resolveAssignee = (assigneeId: string | undefined) => {
|
|
if (!assigneeId) return null
|
|
return knownUsersById.get(assigneeId) || null
|
|
}
|
|
|
|
const setNewTaskAssignee = (assigneeId: string) => {
|
|
if (!assigneeId) {
|
|
setNewTask((prev) => ({
|
|
...prev,
|
|
assigneeId: undefined,
|
|
assigneeName: undefined,
|
|
assigneeEmail: undefined,
|
|
assigneeAvatarUrl: undefined,
|
|
}))
|
|
return
|
|
}
|
|
|
|
const assignee = resolveAssignee(assigneeId)
|
|
setNewTask((prev) => ({
|
|
...prev,
|
|
assigneeId,
|
|
assigneeName: assignee?.name || prev.assigneeName,
|
|
assigneeEmail: assignee?.email,
|
|
assigneeAvatarUrl: assignee?.avatarUrl,
|
|
}))
|
|
}
|
|
|
|
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 handleAddTask = () => {
|
|
if (newTask.title?.trim()) {
|
|
// Use explicitly selected project, or fall back to sprint's project, or current selection, or first project
|
|
const selectedProjectFromTask = newTask.projectId
|
|
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null
|
|
const targetProjectId = selectedProjectFromTask || selectedSprint?.projectId || selectedProjectId || projects[0]?.id
|
|
if (!targetProjectId) {
|
|
toast.error("Cannot create task", {
|
|
description: "No project is available. Create or select a project first.",
|
|
duration: 5000,
|
|
})
|
|
return
|
|
}
|
|
|
|
const taskToCreate: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'> = {
|
|
title: newTask.title.trim(),
|
|
description: newTask.description?.trim() || undefined,
|
|
type: (newTask.type || "task") as TaskType,
|
|
priority: (newTask.priority || "medium") as Priority,
|
|
status: (newTask.status || "open") as TaskStatus,
|
|
tags: newTask.tags || [],
|
|
projectId: targetProjectId,
|
|
sprintId: newTask.sprintId || currentSprint?.id,
|
|
assigneeId: newTask.assigneeId,
|
|
assigneeName: newTask.assigneeName,
|
|
assigneeEmail: newTask.assigneeEmail,
|
|
assigneeAvatarUrl: newTask.assigneeAvatarUrl,
|
|
}
|
|
|
|
addTask(taskToCreate)
|
|
setNewTask({
|
|
title: "",
|
|
description: "",
|
|
type: "task",
|
|
priority: "medium",
|
|
status: "open",
|
|
tags: [],
|
|
projectId: undefined,
|
|
sprintId: undefined,
|
|
assigneeId: currentUser.id,
|
|
assigneeName: currentUser.name,
|
|
assigneeEmail: currentUser.email,
|
|
assigneeAvatarUrl: currentUser.avatarUrl,
|
|
})
|
|
setNewTaskLabelInput("")
|
|
setNewTaskOpen(false)
|
|
}
|
|
}
|
|
|
|
const handleAddComment = () => {
|
|
if (newComment.trim() && selectedTaskId) {
|
|
const commentAuthorId = getCommentAuthorId(currentUser.id)
|
|
if (!commentAuthorId) {
|
|
toast.error("You must be signed in to add a comment.")
|
|
return
|
|
}
|
|
addComment(selectedTaskId, newComment.trim(), commentAuthorId)
|
|
setNewComment("")
|
|
}
|
|
}
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await fetch("/api/auth/logout", { method: "POST" })
|
|
} finally {
|
|
setAuthReady(false)
|
|
router.replace("/login")
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
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 (!initialSyncComplete) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
|
<p className="text-sm text-slate-400">Loading board...</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>
|
|
<h1 className="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
|
OpenClaw Task Hub
|
|
</h1>
|
|
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
|
Track ideas, tasks, bugs, and plans — with threaded comments
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Search Input */}
|
|
<div className="relative hidden sm:block">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search tasks..."
|
|
className="w-48 lg:w-64 pl-9 pr-8 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition-colors"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{isLoading && (
|
|
<span className="flex items-center gap-1 text-xs text-blue-400">
|
|
<svg className="animate-spin h-3 w-3" 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>
|
|
Syncing...
|
|
</span>
|
|
)}
|
|
<span className="hidden md:inline text-sm text-slate-400">
|
|
{tasks.length} tasks · {allLabels.length} labels
|
|
</span>
|
|
{/* Navigation Links */}
|
|
<nav className="hidden md:flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push("/")}
|
|
className={`flex items-center gap-1 text-xs px-3 py-1.5 rounded-md transition-colors ${
|
|
pathname === "/"
|
|
? "bg-slate-700 text-white"
|
|
: "text-slate-400 hover:text-white hover:bg-slate-800"
|
|
}`}
|
|
>
|
|
<LayoutGrid className="w-3.5 h-3.5" />
|
|
Dashboard
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push("/projects")}
|
|
className={`flex items-center gap-1 text-xs px-3 py-1.5 rounded-md transition-colors ${
|
|
pathname === "/projects"
|
|
? "bg-slate-700 text-white"
|
|
: "text-slate-400 hover:text-white hover:bg-slate-800"
|
|
}`}
|
|
>
|
|
<Folder className="w-3.5 h-3.5" />
|
|
Projects
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push("/sprints")}
|
|
className={`flex items-center gap-1 text-xs px-3 py-1.5 rounded-md transition-colors ${
|
|
pathname?.startsWith("/sprints")
|
|
? "bg-slate-700 text-white"
|
|
: "text-slate-400 hover:text-white hover:bg-slate-800"
|
|
}`}
|
|
>
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
Sprints
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push("/sprints/archive")}
|
|
className={`hidden sm:flex items-center gap-1 text-xs px-3 py-1.5 rounded-md transition-colors ${
|
|
pathname === "/sprints/archive"
|
|
? "bg-slate-700 text-white"
|
|
: "text-slate-400 hover:text-white hover:bg-slate-800"
|
|
}`}
|
|
title="View Sprint History"
|
|
>
|
|
<Archive className="w-3.5 h-3.5" />
|
|
Archive
|
|
</button>
|
|
</nav>
|
|
|
|
<div className="flex items-center gap-2 rounded border border-slate-700 px-2 py-1">
|
|
<AvatarCircle name={currentUser.name} avatarUrl={currentUser.avatarUrl} seed={currentUser.id} sizeClass="h-5 w-5" />
|
|
<span className="text-xs text-slate-300">{currentUser.name}</span>
|
|
</div>
|
|
<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"
|
|
>
|
|
Logout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-[1800px] mx-auto px-4 py-4 md:py-6">
|
|
{/* Main Content */}
|
|
<main className="min-w-0">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
|
|
<div>
|
|
<h2 className="text-lg md:text-xl font-semibold text-white">
|
|
{currentSprint ? currentSprint.name : "Work Board"}
|
|
</h2>
|
|
<p className="text-sm text-slate-400">
|
|
{debouncedSearchQuery.trim() ? (
|
|
<>
|
|
{sprintTasks.length} of {currentSprint ? tasks.filter((t) => t.sprintId === currentSprint.id).length : 0} tasks match "{debouncedSearchQuery}"
|
|
</>
|
|
) : (
|
|
<>
|
|
{sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* View Toggle */}
|
|
<div className="flex bg-slate-800 rounded-lg p-1">
|
|
<button
|
|
onClick={() => setViewMode('kanban')}
|
|
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
|
viewMode === 'kanban'
|
|
? 'bg-slate-700 text-white'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
Kanban
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('backlog')}
|
|
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
|
viewMode === 'backlog'
|
|
? 'bg-slate-700 text-white'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
<ListTodo className="w-4 h-4" />
|
|
Backlog
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('search')}
|
|
className={`flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
|
viewMode === 'search'
|
|
? 'bg-slate-700 text-white'
|
|
: 'text-slate-400 hover:text-white'
|
|
}`}
|
|
>
|
|
<Search className="w-4 h-4" />
|
|
Search
|
|
</button>
|
|
</div>
|
|
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Task
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Search - shown only on small screens */}
|
|
<div className="sm:hidden mb-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search tasks..."
|
|
className="w-full pl-9 pr-8 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery("")}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-slate-700 rounded text-slate-400 hover:text-white"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* View Content */}
|
|
{viewMode === 'search' ? (
|
|
<SearchView searchQuery={debouncedSearchQuery} />
|
|
) : viewMode === 'backlog' ? (
|
|
<BacklogView searchQuery={debouncedSearchQuery} />
|
|
) : (
|
|
<>
|
|
{/* Current Sprint Header */}
|
|
<div className="mb-4 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
|
|
<p className="text-sm text-slate-400">
|
|
{currentSprint
|
|
? `${formatSprintDisplayDate(currentSprint.startDate, "start")} - ${formatSprintDisplayDate(currentSprint.endDate, "end")}`
|
|
: "Create or activate a sprint to group work"}
|
|
</p>
|
|
</div>
|
|
{currentSprint && <Badge variant="default">Active</Badge>}
|
|
</div>
|
|
</div>
|
|
{/* Kanban Columns */}
|
|
<DndContext
|
|
sensors={kanbanSensors}
|
|
collisionDetection={closestCorners}
|
|
onDragStart={handleKanbanDragStart}
|
|
onDragOver={handleKanbanDragOver}
|
|
onDragEnd={handleKanbanDragEnd}
|
|
onDragCancel={handleKanbanDragCancel}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{sprintColumns.map((column) => {
|
|
const columnTasks = sprintTasks.filter((t) =>
|
|
column.statuses.includes(t.status)
|
|
)
|
|
return (
|
|
<KanbanDropColumn
|
|
key={column.key}
|
|
id={`kanban-col-${column.key}`}
|
|
label={column.label}
|
|
count={columnTasks.length}
|
|
statusTargets={column.statuses.map((status) => ({
|
|
status,
|
|
count: sprintTasks.filter((task) => task.status === status).length,
|
|
}))}
|
|
isDragging={!!activeKanbanTaskId}
|
|
isActiveDropColumn={dragOverKanbanColumnKey === column.key}
|
|
>
|
|
{columnTasks.map((task) => (
|
|
<KanbanTaskCard
|
|
key={task.id}
|
|
task={task}
|
|
taskTags={getTags(task)}
|
|
assigneeAvatarUrl={resolveAssignee(task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl}
|
|
onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
|
|
onDelete={() => deleteTask(task.id)}
|
|
/>
|
|
))}
|
|
</KanbanDropColumn>
|
|
)
|
|
})}
|
|
</div>
|
|
<DragOverlay>
|
|
{activeKanbanTask ? (
|
|
<Card className="bg-slate-900 border-slate-700 shadow-2xl rotate-1">
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<GripVertical className="w-3.5 h-3.5 text-slate-400" />
|
|
<span className="text-sm font-medium text-white">{activeKanbanTask.title}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{/* New Task Dialog */}
|
|
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
|
|
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-lg max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>New Task</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div>
|
|
<Label>Title</Label>
|
|
<input
|
|
type="text"
|
|
value={newTask.title}
|
|
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
|
className="w-full mt-1.5 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="What needs to be done?"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Description</Label>
|
|
<Textarea
|
|
value={newTask.description}
|
|
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
|
className="mt-1.5 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
|
placeholder="Add more details..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>Type</Label>
|
|
<select
|
|
value={newTask.type}
|
|
onChange={(e) => setNewTask({ ...newTask, type: e.target.value as TaskType })}
|
|
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
{Object.entries(typeLabels).map(([type, label]) => (
|
|
<option key={type} value={type}>{label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label>Priority</Label>
|
|
<select
|
|
value={newTask.priority}
|
|
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value as Priority })}
|
|
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
<option value="urgent">Urgent</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>Status</Label>
|
|
<select
|
|
value={newTask.status}
|
|
onChange={(e) => setNewTask({ ...newTask, status: e.target.value as TaskStatus })}
|
|
className="w-full mt-1.5 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>Sprint (optional)</Label>
|
|
<select
|
|
value={newTask.sprintId || ""}
|
|
onChange={(e) => setNewTask({ ...newTask, sprintId: e.target.value || undefined })}
|
|
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="">Auto (Current Sprint)</option>
|
|
{sprints
|
|
.filter((sprint) => sprint.status !== "completed")
|
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
|
|
.map((sprint) => (
|
|
<option key={sprint.id} value={sprint.id}>
|
|
{sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label>Project</Label>
|
|
<select
|
|
value={newTask.projectId || ""}
|
|
onChange={(e) => setNewTask({ ...newTask, projectId: e.target.value || undefined })}
|
|
className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
|
|
>
|
|
<option value="">Auto (Based on Sprint/Current Selection)</option>
|
|
{projects
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map((project) => (
|
|
<option key={project.id} value={project.id}>
|
|
{project.color ? `● ` : ""}{project.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label>Assignee</Label>
|
|
<select
|
|
value={newTask.assigneeId || ""}
|
|
onChange={(e) => setNewTaskAssignee(e.target.value)}
|
|
className="w-full mt-1.5 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>Labels</Label>
|
|
<div className="mt-1.5 space-y-2">
|
|
{newTask.tags && newTask.tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{newTask.tags.map((label) => (
|
|
<Badge
|
|
key={label}
|
|
variant="secondary"
|
|
className="bg-slate-800 text-slate-300 gap-1"
|
|
>
|
|
{label}
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setNewTask((prev) => ({ ...prev, tags: removeLabel(prev.tags || [], 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="new-task-label-suggestions"
|
|
value={newTaskLabelInput}
|
|
onChange={(e) => setNewTaskLabelInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === ",") {
|
|
e.preventDefault()
|
|
setNewTask((prev) => ({
|
|
...prev,
|
|
tags: addUniqueLabel(prev.tags || [], newTaskLabelInput),
|
|
}))
|
|
setNewTaskLabelInput("")
|
|
}
|
|
}}
|
|
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={() => {
|
|
setNewTask((prev) => ({ ...prev, tags: addUniqueLabel(prev.tags || [], newTaskLabelInput) }))
|
|
setNewTaskLabelInput("")
|
|
}}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
<datalist id="new-task-label-suggestions">
|
|
{allLabels.map((label) => (
|
|
<option key={label} value={label} />
|
|
))}
|
|
</datalist>
|
|
{allLabels.filter((label) => !(newTask.tags || []).some((tag) => tag.toLowerCase() === label.toLowerCase())).slice(0, 8).length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{allLabels
|
|
.filter((label) => !(newTask.tags || []).some((tag) => tag.toLowerCase() === label.toLowerCase()))
|
|
.slice(0, 8)
|
|
.map((label) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
onClick={() => setNewTask((prev) => ({ ...prev, tags: addUniqueLabel(prev.tags || [], label) }))}
|
|
className="text-xs px-2 py-1 rounded-md border border-slate-700 text-slate-300 hover:border-slate-500"
|
|
>
|
|
+ {label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setNewTaskOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleAddTask}>Create Task</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Task Detail Dialog with Comments */}
|
|
<Dialog open={!!selectedTaskId} onOpenChange={() => {
|
|
selectTask(null)
|
|
setEditedTask(null)
|
|
}}>
|
|
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-2xl max-h-[90vh] overflow-y-auto p-4 md:p-6">
|
|
{selectedTask && editedTask && (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle className="sr-only">Task Details</DialogTitle>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<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={(e) => setEditedTask({ ...editedTask, title: e.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}
|
|
/>
|
|
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}
|
|
/>
|
|
Last updated by {editedTask.updatedByName || "Unknown"}
|
|
</span>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6 py-4">
|
|
{/* Description */}
|
|
<div>
|
|
<Label className="text-slate-400">Description</Label>
|
|
<textarea
|
|
value={editedTask.description || ""}
|
|
onChange={(e) => setEditedTask({ ...editedTask, description: e.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>
|
|
|
|
{/* Priority */}
|
|
<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>
|
|
|
|
{/* Status */}
|
|
<div>
|
|
<Label className="text-slate-400">Status</Label>
|
|
<select
|
|
value={editedTask.status}
|
|
onChange={(e) => setEditedTask({ ...editedTask, status: e.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>
|
|
|
|
{/* Sprint */}
|
|
<div>
|
|
<Label className="text-slate-400">Sprint</Label>
|
|
<select
|
|
value={editedTask.sprintId || ""}
|
|
onChange={(e) => setEditedTask({ ...editedTask, sprintId: e.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>
|
|
{sprints
|
|
.filter((sprint) => sprint.status !== "completed")
|
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
|
|
.map((sprint) => (
|
|
<option key={sprint.id} value={sprint.id}>
|
|
{sprint.name} ({formatSprintDisplayDate(sprint.startDate, "start")})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Assignee */}
|
|
<div>
|
|
<Label className="text-slate-400">Assignee</Label>
|
|
<select
|
|
value={editedTask.assigneeId || ""}
|
|
onChange={(e) => setEditedTaskAssignee(e.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>
|
|
|
|
{/* Labels */}
|
|
<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={(e) => setEditedTaskLabelInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === ",") {
|
|
e.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>
|
|
{allLabels.filter((label) => !editedTaskTags.some((tag) => tag.toLowerCase() === label.toLowerCase())).slice(0, 8).length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{allLabels
|
|
.filter((label) => !editedTaskTags.some((tag) => tag.toLowerCase() === label.toLowerCase()))
|
|
.slice(0, 8)
|
|
.map((label) => (
|
|
<button
|
|
key={label}
|
|
type="button"
|
|
onClick={() => setEditedTask({ ...editedTask, tags: addUniqueLabel(editedTaskTags, label) })}
|
|
className="text-xs px-2 py-1 rounded-md border border-slate-700 text-slate-300 hover:border-slate-500"
|
|
>
|
|
+ {label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Attachments */}
|
|
<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>
|
|
|
|
{/* Comments Section */}
|
|
<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 ({editedTask.comments?.length || 0})
|
|
</h4>
|
|
|
|
{/* Comment List */}
|
|
<div className="space-y-4 mb-4">
|
|
{!editedTask.comments || editedTask.comments.length === 0 ? (
|
|
<p className="text-slate-500 text-sm">No comments yet. Add the first one.</p>
|
|
) : (
|
|
editedTask.comments.map((comment) => {
|
|
const authorId = comment.commentAuthorId
|
|
const isAssistant = authorId === "assistant"
|
|
const resolvedAuthor = authorId === currentUser.id ? currentUser : resolveAssignee(authorId)
|
|
const displayName = isAssistant
|
|
? "Assistant"
|
|
: authorId === currentUser.id
|
|
? "You"
|
|
: resolvedAuthor?.name || "Unknown user"
|
|
const resolvedAuthorAvatar = resolvedAuthor?.avatarUrl
|
|
return (
|
|
<div
|
|
key={comment.id}
|
|
className={`flex gap-3 p-3 rounded-lg ${
|
|
isAssistant ? "bg-blue-900/20" : "bg-slate-800/50"
|
|
}`}
|
|
>
|
|
{isAssistant ? (
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium bg-blue-600 text-white">
|
|
AI
|
|
</div>
|
|
) : (
|
|
<AvatarCircle name={displayName} avatarUrl={resolvedAuthorAvatar} seed={authorId} sizeClass="h-8 w-8" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm font-medium text-slate-300">
|
|
{isAssistant ? "Assistant" : displayName}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-500">
|
|
{new Date(comment.createdAt).toLocaleString()}
|
|
</span>
|
|
<button
|
|
onClick={() => deleteComment(selectedTask.id, comment.id)}
|
|
className="text-slate-600 hover:text-red-400"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<p className="text-slate-300 text-sm whitespace-pre-wrap">{comment.text}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Comment */}
|
|
<div className="flex gap-3">
|
|
<Textarea
|
|
value={newComment}
|
|
onChange={(e) => setNewComment(e.target.value)}
|
|
placeholder="Add a comment..."
|
|
className="flex-1 bg-slate-800 border-slate-700 text-white placeholder-slate-500"
|
|
rows={2}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && e.metaKey) {
|
|
handleAddComment()
|
|
}
|
|
}}
|
|
/>
|
|
<Button onClick={handleAddComment} className="self-end">
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter 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={() => {
|
|
deleteTask(selectedTask.id)
|
|
selectTask(null)
|
|
setEditedTask(null)
|
|
}}
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Delete Task
|
|
</Button>
|
|
<div className="flex items-center gap-3">
|
|
<Button variant="ghost" onClick={() => {
|
|
selectTask(null)
|
|
setEditedTask(null)
|
|
}}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => {
|
|
updateTask(editedTask.id, editedTask)
|
|
selectTask(null)
|
|
setEditedTask(null)
|
|
}}>
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|