Refactor tasks to label workflow and simplify board layout

This commit is contained in:
OpenClaw Bot 2026-02-19 22:57:10 -06:00
parent 79bffbf0b8
commit 957907c87a
3 changed files with 390 additions and 264 deletions

View File

@ -50,7 +50,8 @@
], ],
"tags": [ "tags": [
"ui", "ui",
"rewrite" "rewrite",
"Web Projects"
] ]
}, },
{ {
@ -64,7 +65,8 @@
"comments": [], "comments": [],
"tags": [ "tags": [
"ios", "ios",
"social" "social",
"OpenClaw iOS"
], ],
"createdAt": "2026-02-18T17:01:23.109Z", "createdAt": "2026-02-18T17:01:23.109Z",
"updatedAt": "2026-02-20T02:28:23.700Z" "updatedAt": "2026-02-20T02:28:23.700Z"
@ -98,7 +100,8 @@
"gitea", "gitea",
"git", "git",
"automation", "automation",
"infrastructure" "infrastructure",
"Web Projects"
] ]
}, },
{ {
@ -125,7 +128,8 @@
"ux", "ux",
"redesign", "redesign",
"dashboard", "dashboard",
"monitoring" "monitoring",
"Web Projects"
] ]
}, },
{ {
@ -157,7 +161,8 @@
"blog", "blog",
"ui", "ui",
"markdown", "markdown",
"links" "links",
"Web Projects"
] ]
}, },
{ {
@ -184,7 +189,8 @@
"cron", "cron",
"bug", "bug",
"infrastructure", "infrastructure",
"urgent" "urgent",
"Web Projects"
] ]
}, },
{ {
@ -210,7 +216,8 @@
"debugging", "debugging",
"research", "research",
"infrastructure", "infrastructure",
"root-cause" "root-cause",
"Web Projects"
] ]
}, },
{ {
@ -242,7 +249,8 @@
"ui", "ui",
"sync", "sync",
"localstorage", "localstorage",
"real-time" "real-time",
"Web Projects"
] ]
}, },
{ {
@ -268,7 +276,8 @@
"ui", "ui",
"kanban", "kanban",
"feature", "feature",
"priority" "priority",
"Web Projects"
] ]
}, },
{ {
@ -301,7 +310,8 @@
"screenshot", "screenshot",
"macos", "macos",
"openclaw", "openclaw",
"investigation" "investigation",
"Web Projects"
] ]
}, },
{ {
@ -366,7 +376,8 @@
"blog", "blog",
"ui", "ui",
"markdown", "markdown",
"frontend" "frontend",
"Web Projects"
] ]
}, },
{ {
@ -400,7 +411,8 @@
"podcast", "podcast",
"audio", "audio",
"digest", "digest",
"accessibility" "accessibility",
"Web Projects"
] ]
}, },
{ {
@ -444,7 +456,8 @@
"backup", "backup",
"infrastructure", "infrastructure",
"data-persistence", "data-persistence",
"automation" "automation",
"Web Projects"
] ]
}, },
{ {
@ -453,7 +466,10 @@
"title": "Add Sprint functionality to Gantt Board", "title": "Add Sprint functionality to Gantt Board",
"projectId": "2", "projectId": "2",
"sprintId": "sprint-1", "sprintId": "sprint-1",
"updatedAt": "2026-02-20T01:52:57.259Z" "updatedAt": "2026-02-20T01:52:57.259Z",
"tags": [
"Web Projects"
]
} }
], ],
"lastUpdated": 1771556223745, "lastUpdated": 1771556223745,

View File

@ -3,6 +3,8 @@ import { readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path"; import { join } from "path";
const DATA_FILE = join(process.cwd(), "data", "tasks.json"); const DATA_FILE = join(process.cwd(), "data", "tasks.json");
console.log('>>> API ROUTE: module loaded, process.cwd():', process.cwd());
console.log('>>> API ROUTE: DATA_FILE:', DATA_FILE);
interface Task { interface Task {
id: string; id: string;
@ -58,13 +60,20 @@ const defaultData: DataStore = {
}; };
function getData(): DataStore { function getData(): DataStore {
console.log('>>> getData: checking file:', DATA_FILE);
console.log('>>> getData: exists?', existsSync(DATA_FILE));
if (!existsSync(DATA_FILE)) { if (!existsSync(DATA_FILE)) {
console.log('>>> getData: file not found, returning defaultData');
return defaultData; return defaultData;
} }
try { try {
const data = readFileSync(DATA_FILE, "utf-8"); const rawData = readFileSync(DATA_FILE, "utf-8");
return JSON.parse(data); console.log('>>> getData: read file, length:', rawData.length);
} catch { const data = JSON.parse(rawData);
console.log('>>> getData: parsed data has', data.tasks?.length, 'tasks');
return data;
} catch (err) {
console.error('>>> getData: error reading/parsing file:', err);
return defaultData; return defaultData;
} }
} }
@ -80,7 +89,11 @@ function saveData(data: DataStore) {
// GET - fetch all tasks, projects, and sprints // GET - fetch all tasks, projects, and sprints
export async function GET() { export async function GET() {
console.log('>>> API GET: fetching data');
console.log('>>> API GET: DATA_FILE path:', DATA_FILE);
const data = getData(); const data = getData();
console.log('>>> API GET: returning data with', data.tasks?.length, 'tasks,', data.projects?.length, 'projects,', data.sprints?.length, 'sprints');
console.log('>>> API GET: lastUpdated:', data.lastUpdated);
return NextResponse.json(data); return NextResponse.json(data);
} }

View File

@ -1,15 +1,15 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useMemo } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, Project } from "@/stores/useTaskStore" import { useTaskStore, Task, TaskType, TaskStatus, Priority } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView" import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Tag, Trash2, Edit2, X, Check, MoreHorizontal, LayoutGrid, ListTodo } from "lucide-react" import { Plus, MessageSquare, Calendar, Trash2, Edit2, X, LayoutGrid, ListTodo } from "lucide-react"
const typeColors: Record<TaskType, string> = { const typeColors: Record<TaskType, string> = {
idea: "bg-purple-500", idea: "bg-purple-500",
@ -63,23 +63,16 @@ export default function Home() {
sprints, sprints,
selectedProjectId, selectedProjectId,
selectedTaskId, selectedTaskId,
selectedSprintId,
selectProject,
addProject,
deleteProject,
addTask, addTask,
updateTask, updateTask,
deleteTask, deleteTask,
selectTask, selectTask,
addComment, addComment,
deleteComment, deleteComment,
getTasksByProject,
syncFromServer, syncFromServer,
isLoading, isLoading,
} = useTaskStore() } = useTaskStore()
const [newProjectName, setNewProjectName] = useState("")
const [showNewProject, setShowNewProject] = useState(false)
const [newTaskOpen, setNewTaskOpen] = useState(false) const [newTaskOpen, setNewTaskOpen] = useState(false)
const [newTask, setNewTask] = useState<Partial<Task>>({ const [newTask, setNewTask] = useState<Partial<Task>>({
title: "", title: "",
@ -90,17 +83,33 @@ export default function Home() {
tags: [], tags: [],
}) })
const [newComment, setNewComment] = useState("") const [newComment, setNewComment] = useState("")
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [viewMode, setViewMode] = useState<'kanban' | 'backlog'>('kanban') const [viewMode, setViewMode] = useState<'kanban' | 'backlog'>('kanban')
const [editedTask, setEditedTask] = useState<Task | null>(null) const [editedTask, setEditedTask] = useState<Task | null>(null)
const [newTaskLabelInput, setNewTaskLabelInput] = useState("")
const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("")
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 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])
// Sync from server on mount // Sync from server on mount
useEffect(() => { useEffect(() => {
console.log('>>> PAGE: useEffect for syncFromServer running') console.log('>>> PAGE: useEffect for syncFromServer running')
console.log('>>> PAGE: tasks count before sync:', tasks.length)
syncFromServer().then(() => { syncFromServer().then(() => {
console.log('>>> PAGE: syncFromServer completed') console.log('>>> PAGE: syncFromServer completed')
console.log('>>> PAGE: tasks count after sync:', tasks.length)
}) })
}, [syncFromServer]) }, [syncFromServer])
@ -109,16 +118,16 @@ export default function Home() {
console.log('>>> PAGE: tasks changed, new count:', tasks.length) console.log('>>> PAGE: tasks changed, new count:', tasks.length)
}, [tasks]) }, [tasks])
const selectedProject = projects.find((p) => p.id === selectedProjectId)
const selectedTask = tasks.find((t) => t.id === selectedTaskId) const selectedTask = tasks.find((t) => t.id === selectedTaskId)
const editedTaskTags = editedTask ? getTags(editedTask) : []
// Set editedTask when selectedTask changes
useEffect(() => { useEffect(() => {
if (selectedTask) { if (selectedTask) {
setEditedTask({ ...selectedTask }) // eslint-disable-next-line react-hooks/set-state-in-effect
setEditedTask({ ...selectedTask, tags: getTags(selectedTask) })
setEditedTaskLabelInput("")
} }
}, [selectedTask]) }, [selectedTask])
const projectTasks = selectedProjectId ? getTasksByProject(selectedProjectId) : []
// Get current active sprint (across all projects) // Get current active sprint (across all projects)
const now = new Date() const now = new Date()
@ -133,27 +142,38 @@ export default function Home() {
? tasks.filter((t) => t.sprintId === currentSprint.id) ? tasks.filter((t) => t.sprintId === currentSprint.id)
: [] : []
const handleAddProject = () => { const toLabel = (raw: string) => raw.trim().replace(/^#/, "")
if (newProjectName.trim()) {
addProject(newProjectName.trim()) const addUniqueLabel = (existing: string[], raw: string) => {
setNewProjectName("") const nextLabel = toLabel(raw)
setShowNewProject(false) 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 handleAddTask = () => { const handleAddTask = () => {
if (newTask.title?.trim()) { if (newTask.title?.trim()) {
// If a specific sprint is selected, use that sprint's project // If a specific sprint is selected, use that sprint's project
const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null
const targetProjectId = selectedSprint?.projectId || selectedProjectId || '2' const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id || '2'
addTask({ const taskToCreate: Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'comments'> = {
...newTask, title: newTask.title.trim(),
description: newTask.description?.trim() || undefined,
type: (newTask.type || "task") as TaskType,
priority: (newTask.priority || "medium") as Priority,
status: (newTask.status || "backlog") as TaskStatus,
tags: newTask.tags || [],
projectId: targetProjectId, projectId: targetProjectId,
status: newTask.status || "backlog", sprintId: newTask.sprintId || currentSprint?.id,
sprintId: newTask.sprintId || currentSprint?.id, // Use selected sprint or default to current }
} as any)
addTask(taskToCreate)
setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "backlog", tags: [], sprintId: undefined }) setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "backlog", tags: [], sprintId: undefined })
setNewTaskLabelInput("")
setNewTaskOpen(false) setNewTaskOpen(false)
} }
} }
@ -165,10 +185,6 @@ export default function Home() {
} }
} }
const handleUpdateTaskStatus = (taskId: string, newStatus: TaskStatus) => {
updateTask(taskId, { status: newStatus })
}
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-100"> <div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */} {/* Header */}
@ -177,7 +193,7 @@ export default function Home() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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"> <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 Project Hub OpenClaw Task Hub
</h1> </h1>
<p className="text-xs md:text-sm text-slate-400 mt-1"> <p className="text-xs md:text-sm text-slate-400 mt-1">
Track ideas, tasks, bugs, and plans with threaded notes Track ideas, tasks, bugs, and plans with threaded notes
@ -194,7 +210,7 @@ export default function Home() {
</span> </span>
)} )}
<span className="hidden md:inline text-sm text-slate-400"> <span className="hidden md:inline text-sm text-slate-400">
{tasks.length} tasks · {projects.length} projects {tasks.length} tasks · {allLabels.length} labels
</span> </span>
</div> </div>
</div> </div>
@ -202,122 +218,70 @@ export default function Home() {
</header> </header>
<div className="max-w-[1800px] mx-auto px-4 py-4 md:py-6"> <div className="max-w-[1800px] mx-auto px-4 py-4 md:py-6">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6"> {/* Main Content */}
{/* Sidebar - Sprint Info */} <main className="min-w-0">
<aside className="w-full lg:w-64 shrink-0"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
<Card className="bg-slate-900 border-slate-800"> <div>
<CardHeader className="pb-3"> <h2 className="text-lg md:text-xl font-semibold text-white">
<CardTitle className="text-sm font-semibold text-slate-400 uppercase tracking-wider"> {currentSprint ? currentSprint.name : "Work Board"}
Current Sprint </h2>
</CardTitle> <p className="text-sm text-slate-400">
</CardHeader> {sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
<CardContent className="space-y-3"> </p>
{currentSprint ? ( </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>
</div>
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
<Plus className="w-4 h-4 mr-2" />
Add Task
</Button>
</div>
</div>
{/* View Content */}
{viewMode === 'backlog' ? (
<BacklogView />
) : (
<>
{/* 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> <div>
<h3 className="font-medium text-white">{currentSprint.name}</h3> <h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
<p className="text-sm text-slate-400"> <p className="text-sm text-slate-400">
{new Date(currentSprint.startDate).toLocaleDateString()} - {new Date(currentSprint.endDate).toLocaleDateString()} {currentSprint
? `${new Date(currentSprint.startDate).toLocaleDateString()} - ${new Date(currentSprint.endDate).toLocaleDateString()}`
: "Create or activate a sprint to group work"}
</p> </p>
</div> </div>
<div className="flex items-center gap-4 text-sm"> {currentSprint && <Badge variant="default">Active</Badge>}
<span className="text-slate-400">
<strong className="text-white">{sprintTasks.length}</strong> tasks
</span>
<span className="text-slate-400">
<strong className="text-white">{sprintTasks.filter(t => t.status === 'done').length}</strong> done
</span>
</div>
</>
) : (
<p className="text-sm text-slate-500">No active sprint</p>
)}
</CardContent>
</Card>
{/* Projects Quick View */}
<Card className="bg-slate-900 border-slate-800 mt-4">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold text-slate-400 uppercase tracking-wider">
Projects
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{projects.map((project) => (
<div key={project.id} className="flex items-center gap-2 text-sm">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: project.color }}
/>
<span className="text-slate-400">{project.name}</span>
</div>
))}
</CardContent>
</Card>
</aside>
{/* Main Content */}
<main className="flex-1 min-w-0">
{selectedProject ? (
<>
<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">
{selectedProject.name}
</h2>
<p className="text-sm text-slate-400">
{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>
</div>
<Button onClick={() => setNewTaskOpen(true)} className="w-full sm:w-auto">
<Plus className="w-4 h-4 mr-2" />
Add Task
</Button>
</div> </div>
</div> </div>
{/* Kanban Columns */}
{/* View Content */}
{viewMode === 'backlog' ? (
<BacklogView />
) : (
<>
{/* 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">Sprint 1</h3>
<p className="text-sm text-slate-400">Feb 16 - Feb 22, 2026</p>
</div>
<Badge variant="default">Active</Badge>
</div>
</div>
{/* Kanban Columns */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{sprintColumns.map((column) => { {sprintColumns.map((column) => {
// Filter tasks by column statuses // Filter tasks by column statuses
@ -337,111 +301,96 @@ export default function Home() {
<div className="space-y-3"> <div className="space-y-3">
{columnTasks.map((task) => { {columnTasks.map((task) => {
const taskProject = projects.find((p) => p.id === task.projectId) const taskTags = getTags(task)
return ( return (
<Card <Card
key={task.id} key={task.id}
className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group" className="bg-slate-900 border-slate-800 hover:border-slate-700 cursor-pointer transition-colors group"
onClick={() => selectTask(task.id)} onClick={() => selectTask(task.id)}
> >
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge
variant="outline"
className={`text-xs ${typeColors[task.type]} text-white border-0`}
>
{typeLabels[task.type]}
</Badge>
{taskProject && (
<Badge <Badge
variant="outline" variant="outline"
className="text-xs text-white border-0" className={`text-xs ${typeColors[task.type]} text-white border-0`}
style={{ backgroundColor: taskProject.color }}
> >
{taskProject.name} {typeLabels[task.type]}
</Badge> </Badge>
)} </div>
</div> <div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"> <button
<button onClick={(e) => {
onClick={(e) => { e.stopPropagation()
e.stopPropagation() selectTask(task.id)
setEditingTask(task) }}
}} className="p-1 hover:bg-slate-800 rounded"
className="p-1 hover:bg-slate-800 rounded"
>
<Edit2 className="w-3 h-3 text-slate-400" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
deleteTask(task.id)
}}
className="p-1 hover:bg-slate-800 rounded"
>
<Trash2 className="w-3 h-3 text-slate-400" />
</button>
</div>
</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">
<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>
)}
</div>
{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>
{task.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{task.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
> >
{tag} <Edit2 className="w-3 h-3 text-slate-400" />
</span> </button>
))} <button
onClick={(e) => {
e.stopPropagation()
deleteTask(task.id)
}}
className="p-1 hover:bg-slate-800 rounded"
>
<Trash2 className="w-3 h-3 text-slate-400" />
</button>
</div>
</div> </div>
)}
</CardContent> <h4 className="font-medium text-white mb-1">{task.title}</h4>
</Card> {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">
<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>
)}
</div>
{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>
{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>
)
})}
</div> </div>
</div> </div>
) )
})} })}
</div> </div>
</>
)}
</> </>
) : (
<div className="flex items-center justify-center h-96 text-slate-500">
<p>Select a project to view tasks</p>
</div>
)} )}
</main> </main>
</div>
</div> </div>
{/* New Task Dialog */} {/* New Task Dialog */}
@ -528,13 +477,83 @@ export default function Home() {
</div> </div>
</div> </div>
<div> <div>
<Label>Tags (comma separated)</Label> <Label>Labels</Label>
<input <div className="mt-1.5 space-y-2">
type="text" {newTask.tags && newTask.tags.length > 0 && (
onChange={(e) => setNewTask({ ...newTask, tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean) })} <div className="flex flex-wrap gap-2">
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" {newTask.tags.map((label) => (
placeholder="ios, backend, urgent" <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>
</div> </div>
<DialogFooter> <DialogFooter>
@ -640,6 +659,84 @@ export default function Home() {
</select> </select>
</div> </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>
{/* Comments Section */} {/* Comments Section */}
<div className="border-t border-slate-800 pt-6"> <div className="border-t border-slate-800 pt-6">
<h4 className="font-medium text-white mb-4 flex items-center gap-2"> <h4 className="font-medium text-white mb-4 flex items-center gap-2">