Refactor tasks to label workflow and simplify board layout
This commit is contained in:
parent
79bffbf0b8
commit
957907c87a
@ -50,7 +50,8 @@
|
||||
],
|
||||
"tags": [
|
||||
"ui",
|
||||
"rewrite"
|
||||
"rewrite",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -64,7 +65,8 @@
|
||||
"comments": [],
|
||||
"tags": [
|
||||
"ios",
|
||||
"social"
|
||||
"social",
|
||||
"OpenClaw iOS"
|
||||
],
|
||||
"createdAt": "2026-02-18T17:01:23.109Z",
|
||||
"updatedAt": "2026-02-20T02:28:23.700Z"
|
||||
@ -98,7 +100,8 @@
|
||||
"gitea",
|
||||
"git",
|
||||
"automation",
|
||||
"infrastructure"
|
||||
"infrastructure",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -125,7 +128,8 @@
|
||||
"ux",
|
||||
"redesign",
|
||||
"dashboard",
|
||||
"monitoring"
|
||||
"monitoring",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -157,7 +161,8 @@
|
||||
"blog",
|
||||
"ui",
|
||||
"markdown",
|
||||
"links"
|
||||
"links",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -184,7 +189,8 @@
|
||||
"cron",
|
||||
"bug",
|
||||
"infrastructure",
|
||||
"urgent"
|
||||
"urgent",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -210,7 +216,8 @@
|
||||
"debugging",
|
||||
"research",
|
||||
"infrastructure",
|
||||
"root-cause"
|
||||
"root-cause",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -242,7 +249,8 @@
|
||||
"ui",
|
||||
"sync",
|
||||
"localstorage",
|
||||
"real-time"
|
||||
"real-time",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -268,7 +276,8 @@
|
||||
"ui",
|
||||
"kanban",
|
||||
"feature",
|
||||
"priority"
|
||||
"priority",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -301,7 +310,8 @@
|
||||
"screenshot",
|
||||
"macos",
|
||||
"openclaw",
|
||||
"investigation"
|
||||
"investigation",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -366,7 +376,8 @@
|
||||
"blog",
|
||||
"ui",
|
||||
"markdown",
|
||||
"frontend"
|
||||
"frontend",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -400,7 +411,8 @@
|
||||
"podcast",
|
||||
"audio",
|
||||
"digest",
|
||||
"accessibility"
|
||||
"accessibility",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -444,7 +456,8 @@
|
||||
"backup",
|
||||
"infrastructure",
|
||||
"data-persistence",
|
||||
"automation"
|
||||
"automation",
|
||||
"Web Projects"
|
||||
]
|
||||
},
|
||||
{
|
||||
@ -453,7 +466,10 @@
|
||||
"title": "Add Sprint functionality to Gantt Board",
|
||||
"projectId": "2",
|
||||
"sprintId": "sprint-1",
|
||||
"updatedAt": "2026-02-20T01:52:57.259Z"
|
||||
"updatedAt": "2026-02-20T01:52:57.259Z",
|
||||
"tags": [
|
||||
"Web Projects"
|
||||
]
|
||||
}
|
||||
],
|
||||
"lastUpdated": 1771556223745,
|
||||
|
||||
@ -3,6 +3,8 @@ import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
@ -58,13 +60,20 @@ const defaultData: DataStore = {
|
||||
};
|
||||
|
||||
function getData(): DataStore {
|
||||
console.log('>>> getData: checking file:', DATA_FILE);
|
||||
console.log('>>> getData: exists?', existsSync(DATA_FILE));
|
||||
if (!existsSync(DATA_FILE)) {
|
||||
console.log('>>> getData: file not found, returning defaultData');
|
||||
return defaultData;
|
||||
}
|
||||
try {
|
||||
const data = readFileSync(DATA_FILE, "utf-8");
|
||||
return JSON.parse(data);
|
||||
} catch {
|
||||
const rawData = readFileSync(DATA_FILE, "utf-8");
|
||||
console.log('>>> getData: read file, length:', rawData.length);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -80,7 +89,11 @@ function saveData(data: DataStore) {
|
||||
|
||||
// GET - fetch all tasks, projects, and sprints
|
||||
export async function GET() {
|
||||
console.log('>>> API GET: fetching data');
|
||||
console.log('>>> API GET: DATA_FILE path:', DATA_FILE);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
341
src/app/page.tsx
341
src/app/page.tsx
@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
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 { 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> = {
|
||||
idea: "bg-purple-500",
|
||||
@ -63,23 +63,16 @@ export default function Home() {
|
||||
sprints,
|
||||
selectedProjectId,
|
||||
selectedTaskId,
|
||||
selectedSprintId,
|
||||
selectProject,
|
||||
addProject,
|
||||
deleteProject,
|
||||
addTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
selectTask,
|
||||
addComment,
|
||||
deleteComment,
|
||||
getTasksByProject,
|
||||
syncFromServer,
|
||||
isLoading,
|
||||
} = useTaskStore()
|
||||
|
||||
const [newProjectName, setNewProjectName] = useState("")
|
||||
const [showNewProject, setShowNewProject] = useState(false)
|
||||
const [newTaskOpen, setNewTaskOpen] = useState(false)
|
||||
const [newTask, setNewTask] = useState<Partial<Task>>({
|
||||
title: "",
|
||||
@ -90,17 +83,33 @@ export default function Home() {
|
||||
tags: [],
|
||||
})
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [viewMode, setViewMode] = useState<'kanban' | 'backlog'>('kanban')
|
||||
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
|
||||
useEffect(() => {
|
||||
console.log('>>> PAGE: useEffect for syncFromServer running')
|
||||
console.log('>>> PAGE: tasks count before sync:', tasks.length)
|
||||
syncFromServer().then(() => {
|
||||
console.log('>>> PAGE: syncFromServer completed')
|
||||
console.log('>>> PAGE: tasks count after sync:', tasks.length)
|
||||
})
|
||||
}, [syncFromServer])
|
||||
|
||||
@ -109,16 +118,16 @@ export default function Home() {
|
||||
console.log('>>> PAGE: tasks changed, new count:', tasks.length)
|
||||
}, [tasks])
|
||||
|
||||
const selectedProject = projects.find((p) => p.id === selectedProjectId)
|
||||
const selectedTask = tasks.find((t) => t.id === selectedTaskId)
|
||||
const editedTaskTags = editedTask ? getTags(editedTask) : []
|
||||
|
||||
// Set editedTask when selectedTask changes
|
||||
useEffect(() => {
|
||||
if (selectedTask) {
|
||||
setEditedTask({ ...selectedTask })
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setEditedTask({ ...selectedTask, tags: getTags(selectedTask) })
|
||||
setEditedTaskLabelInput("")
|
||||
}
|
||||
}, [selectedTask])
|
||||
const projectTasks = selectedProjectId ? getTasksByProject(selectedProjectId) : []
|
||||
|
||||
// Get current active sprint (across all projects)
|
||||
const now = new Date()
|
||||
@ -133,27 +142,38 @@ export default function Home() {
|
||||
? tasks.filter((t) => t.sprintId === currentSprint.id)
|
||||
: []
|
||||
|
||||
const handleAddProject = () => {
|
||||
if (newProjectName.trim()) {
|
||||
addProject(newProjectName.trim())
|
||||
setNewProjectName("")
|
||||
setShowNewProject(false)
|
||||
}
|
||||
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 handleAddTask = () => {
|
||||
if (newTask.title?.trim()) {
|
||||
// If a specific sprint is selected, use that sprint's project
|
||||
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({
|
||||
...newTask,
|
||||
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 || "backlog") as TaskStatus,
|
||||
tags: newTask.tags || [],
|
||||
projectId: targetProjectId,
|
||||
status: newTask.status || "backlog",
|
||||
sprintId: newTask.sprintId || currentSprint?.id, // Use selected sprint or default to current
|
||||
} as any)
|
||||
sprintId: newTask.sprintId || currentSprint?.id,
|
||||
}
|
||||
|
||||
addTask(taskToCreate)
|
||||
setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "backlog", tags: [], sprintId: undefined })
|
||||
setNewTaskLabelInput("")
|
||||
setNewTaskOpen(false)
|
||||
}
|
||||
}
|
||||
@ -165,10 +185,6 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateTaskStatus = (taskId: string, newStatus: TaskStatus) => {
|
||||
updateTask(taskId, { status: newStatus })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||
{/* Header */}
|
||||
@ -177,7 +193,7 @@ export default function Home() {
|
||||
<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 Project Hub
|
||||
OpenClaw Task Hub
|
||||
</h1>
|
||||
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
||||
Track ideas, tasks, bugs, and plans — with threaded notes
|
||||
@ -194,7 +210,7 @@ export default function Home() {
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden md:inline text-sm text-slate-400">
|
||||
{tasks.length} tasks · {projects.length} projects
|
||||
{tasks.length} tasks · {allLabels.length} labels
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -202,68 +218,12 @@ export default function Home() {
|
||||
</header>
|
||||
|
||||
<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">
|
||||
{/* Sidebar - Sprint Info */}
|
||||
<aside className="w-full lg:w-64 shrink-0">
|
||||
<Card className="bg-slate-900 border-slate-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Current Sprint
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{currentSprint ? (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">{currentSprint.name}</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
{new Date(currentSprint.startDate).toLocaleDateString()} - {new Date(currentSprint.endDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<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 ? (
|
||||
<>
|
||||
<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">
|
||||
{selectedProject.name}
|
||||
{currentSprint ? currentSprint.name : "Work Board"}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
{sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
|
||||
@ -311,10 +271,14 @@ export default function Home() {
|
||||
<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>
|
||||
<h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
{currentSprint
|
||||
? `${new Date(currentSprint.startDate).toLocaleDateString()} - ${new Date(currentSprint.endDate).toLocaleDateString()}`
|
||||
: "Create or activate a sprint to group work"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="default">Active</Badge>
|
||||
{currentSprint && <Badge variant="default">Active</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Kanban Columns */}
|
||||
@ -337,7 +301,7 @@ export default function Home() {
|
||||
|
||||
<div className="space-y-3">
|
||||
{columnTasks.map((task) => {
|
||||
const taskProject = projects.find((p) => p.id === task.projectId)
|
||||
const taskTags = getTags(task)
|
||||
return (
|
||||
<Card
|
||||
key={task.id}
|
||||
@ -353,21 +317,12 @@ export default function Home() {
|
||||
>
|
||||
{typeLabels[task.type]}
|
||||
</Badge>
|
||||
{taskProject && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-white border-0"
|
||||
style={{ backgroundColor: taskProject.color }}
|
||||
>
|
||||
{taskProject.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingTask(task)
|
||||
selectTask(task.id)
|
||||
}}
|
||||
className="p-1 hover:bg-slate-800 rounded"
|
||||
>
|
||||
@ -412,9 +367,9 @@ export default function Home() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.tags.length > 0 && (
|
||||
{taskTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{task.tags.map((tag) => (
|
||||
{taskTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
|
||||
@ -426,7 +381,8 @@ export default function Home() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -434,15 +390,8 @@ export default function Home() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-96 text-slate-500">
|
||||
<p>Select a project to view tasks</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Task Dialog */}
|
||||
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
|
||||
@ -528,13 +477,83 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Tags (comma separated)</Label>
|
||||
<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"
|
||||
onChange={(e) => setNewTask({ ...newTask, tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean) })}
|
||||
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="ios, backend, urgent"
|
||||
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>
|
||||
@ -640,6 +659,84 @@ export default function Home() {
|
||||
</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>
|
||||
|
||||
{/* Comments Section */}
|
||||
<div className="border-t border-slate-800 pt-6">
|
||||
<h4 className="font-medium text-white mb-4 flex items-center gap-2">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user