Refactor tasks to label workflow and simplify board layout
This commit is contained in:
parent
79bffbf0b8
commit
957907c87a
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
341
src/app/page.tsx
341
src/app/page.tsx
@ -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,68 +218,12 @@ 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">
|
|
||||||
{/* 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 Content */}
|
||||||
<main className="flex-1 min-w-0">
|
<main className="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 className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg md:text-xl font-semibold text-white">
|
<h2 className="text-lg md:text-xl font-semibold text-white">
|
||||||
{selectedProject.name}
|
{currentSprint ? currentSprint.name : "Work Board"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-400">
|
<p className="text-sm text-slate-400">
|
||||||
{sprintTasks.length} tasks · {sprintTasks.filter((t) => t.status === "done").length} done
|
{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="mb-4 p-3 bg-slate-800/50 border border-slate-700 rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-white">Sprint 1</h3>
|
<h3 className="font-medium text-white">{currentSprint?.name || "No Active Sprint"}</h3>
|
||||||
<p className="text-sm text-slate-400">Feb 16 - Feb 22, 2026</p>
|
<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>
|
</div>
|
||||||
<Badge variant="default">Active</Badge>
|
{currentSprint && <Badge variant="default">Active</Badge>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Kanban Columns */}
|
{/* Kanban Columns */}
|
||||||
@ -337,7 +301,7 @@ 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}
|
||||||
@ -353,21 +317,12 @@ export default function Home() {
|
|||||||
>
|
>
|
||||||
{typeLabels[task.type]}
|
{typeLabels[task.type]}
|
||||||
</Badge>
|
</Badge>
|
||||||
{taskProject && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs text-white border-0"
|
|
||||||
style={{ backgroundColor: taskProject.color }}
|
|
||||||
>
|
|
||||||
{taskProject.name}
|
|
||||||
</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()
|
||||||
setEditingTask(task)
|
selectTask(task.id)
|
||||||
}}
|
}}
|
||||||
className="p-1 hover:bg-slate-800 rounded"
|
className="p-1 hover:bg-slate-800 rounded"
|
||||||
>
|
>
|
||||||
@ -412,9 +367,9 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.tags.length > 0 && (
|
{taskTags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
{task.tags.map((tag) => (
|
{taskTags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
|
className="text-xs px-2 py-0.5 bg-slate-800 text-slate-400 rounded"
|
||||||
@ -426,7 +381,8 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)})}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -434,15 +390,8 @@ export default function Home() {
|
|||||||
</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 */}
|
||||||
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
|
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
|
||||||
@ -528,13 +477,83 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
onChange={(e) => setNewTask({ ...newTask, tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean) })}
|
list="new-task-label-suggestions"
|
||||||
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"
|
value={newTaskLabelInput}
|
||||||
placeholder="ios, backend, urgent"
|
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">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user