| |
| |
| "use client"; |
|
|
| import Link from "next/link"; |
| import { usePathname } from "next/navigation"; |
| import { useEffect, useMemo, useState } from "react"; |
| import { ensureDefaultProject, listTasks } from "@/lib/api"; |
| import type { TaskRecord } from "@/lib/types"; |
|
|
| function formatRelative(ts: number): string { |
| const diff = Math.max(0, Date.now() - ts); |
| const s = Math.floor(diff / 1000); |
| if (s < 60) return `${s}s ago`; |
| const m = Math.floor(s / 60); |
| if (m < 60) return `${m}m ago`; |
| const h = Math.floor(m / 60); |
| if (h < 24) return `${h}h ago`; |
| const d = Math.floor(h / 24); |
| return `${d}d ago`; |
| } |
|
|
| export default function TaskList() { |
| const pathname = usePathname(); |
| const [projectId, setProjectId] = useState<string | null>(null); |
| const [tasks, setTasks] = useState<TaskRecord[]>([]); |
| const [query, setQuery] = useState(""); |
|
|
| |
| useEffect(() => { |
| let cancelled = false; |
| ensureDefaultProject() |
| .then((p) => { |
| if (!cancelled) setProjectId(p.id); |
| }) |
| .catch(() => { |
| |
| }); |
| return () => { |
| cancelled = true; |
| }; |
| }, []); |
|
|
| |
| useEffect(() => { |
| if (!projectId) return; |
| let cancelled = false; |
|
|
| const fetchTasks = async () => { |
| try { |
| const { tasks: next } = await listTasks(projectId); |
| if (!cancelled) setTasks(next); |
| } catch { |
| |
| } |
| }; |
|
|
| fetchTasks(); |
| const id = setInterval(fetchTasks, 5000); |
| return () => { |
| cancelled = true; |
| clearInterval(id); |
| }; |
| }, [projectId]); |
|
|
| const filtered = useMemo(() => { |
| const q = query.trim().toLowerCase(); |
| const sorted = [...tasks].sort((a, b) => b.updatedAt - a.updatedAt); |
| if (!q) return sorted; |
| return sorted.filter((t) => t.title.toLowerCase().includes(q)); |
| }, [tasks, query]); |
|
|
| const activeId = useMemo(() => { |
| const m = pathname?.match(/\/tasks\/([^/]+)/); |
| return m ? m[1] : null; |
| }, [pathname]); |
|
|
| return ( |
| <div className="flex flex-col min-h-0"> |
| <div className="px-3 pt-1 pb-2"> |
| <p className="text-[10px] font-semibold uppercase tracking-widest text-muted-fg px-2 mb-1.5"> |
| Tasks |
| </p> |
| <input |
| type="text" |
| value={query} |
| onChange={(e) => setQuery(e.target.value)} |
| placeholder="Search tasks…" |
| className="w-full rounded-lg border border-border bg-white px-3 py-1.5 text-xs text-foreground placeholder:text-muted-fg focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20" |
| /> |
| </div> |
| |
| {filtered.length === 0 ? ( |
| <div className="px-4 py-6 text-center flex flex-col items-center"> |
| {/* Beaker / flask illustration */} |
| <svg className="w-10 h-10 mb-2" viewBox="0 0 40 40" fill="none" aria-hidden="true"> |
| {/* Flask body */} |
| <path |
| d="M16 8 L16 18 L8 34 C7 36 8.5 38 11 38 L29 38 C31.5 38 33 36 32 34 L24 18 L24 8" |
| stroke="currentColor" className="text-muted-fg/30" strokeWidth="1.2" strokeLinejoin="round" fill="currentColor" fillOpacity="0" |
| /> |
| {/* Liquid fill */} |
| <path |
| d="M12.5 28 L16.8 20 L23.2 20 L27.5 28 L30 33 C30.5 34 29.8 35 29 35 L11 35 C10.2 35 9.5 34 10 33 Z" |
| fill="currentColor" className="text-accent/[0.08]" |
| /> |
| {/* Flask neck rim */} |
| <line x1="14" y1="8" x2="26" y2="8" stroke="currentColor" className="text-muted-fg/30" strokeWidth="1.2" strokeLinecap="round" /> |
| {/* Bubbles */} |
| <circle cx="17" cy="30" r="1.5" fill="currentColor" className="text-accent/20" /> |
| <circle cx="22" cy="27" r="1" fill="currentColor" className="text-accent/15" /> |
| <circle cx="20" cy="32" r="1.2" fill="currentColor" className="text-accent/20" /> |
| </svg> |
| <p className="text-[11px] text-muted-fg leading-relaxed"> |
| No tasks yet — start a chat to create one. |
| </p> |
| </div> |
| ) : ( |
| <ul className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5"> |
| {filtered.map((t) => { |
| const isActive = t.id === activeId; |
| return ( |
| <li key={t.id}> |
| <Link |
| href={`/projects/${t.projectId}/tasks/${t.id}`} |
| className={`group flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors ${ |
| isActive |
| ? "bg-accent/10 text-foreground" |
| : "text-foreground-dim hover:bg-muted hover:text-foreground" |
| }`} |
| > |
| <span |
| className={`shrink-0 w-1.5 h-1.5 rounded-full ${ |
| isActive ? "bg-accent" : "bg-muted-fg/40 group-hover:bg-accent/60" |
| }`} |
| /> |
| <span className="flex-1 min-w-0 text-xs font-medium truncate">{t.title}</span> |
| <span className="shrink-0 text-[10px] text-muted-fg font-mono"> |
| {formatRelative(t.updatedAt)} |
| </span> |
| </Link> |
| </li> |
| ); |
| })} |
| </ul> |
| )} |
| </div> |
| ); |
| } |
|
|