Download src/app/_components/TaskList.tsx from meljendy/proteinea: direct link, hf CLI and curl.
- Browser
- Download file 5.56 kB
-
https://huggingface.co/spaces/meljendy/proteinea/resolve/main/src/app/_components/TaskList.tsx
- Command line
-
hf download hf://spaces/meljendy/proteinea/src/app/_components/TaskList.tsx
-
curl -L -o TaskList.tsx https://huggingface.co/spaces/meljendy/proteinea/resolve/main/src/app/_components/TaskList.tsx
5.56 kB
| // TaskList.tsx — left-rail task list. Polls listTasks every 5s, supports client-side search, | |
| // highlights the active task based on the current pathname. | |
| "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(""); | |
| // Resolve default project once on mount. | |
| useEffect(() => { | |
| let cancelled = false; | |
| ensureDefaultProject() | |
| .then((p) => { | |
| if (!cancelled) setProjectId(p.id); | |
| }) | |
| .catch(() => { | |
| /* Swallow — empty state will render. */ | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, []); | |
| // Poll tasks every 5s while projectId is known and component is mounted. | |
| useEffect(() => { | |
| if (!projectId) return; | |
| let cancelled = false; | |
| const fetchTasks = async () => { | |
| try { | |
| const { tasks: next } = await listTasks(projectId); | |
| if (!cancelled) setTasks(next); | |
| } catch { | |
| /* Keep last-known list on transient failure. */ | |
| } | |
| }; | |
| 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> | |
| ); | |
| } | |