SPB Git
10.0 KB · 79 lines tsx
1"use client";2/** Research workspace panel rendered above the editor of a research page (status, venue, deadline, repo, datasets + tabs). */3import { useEffect, useRef, useState } from "react";4import { useQuery, useQueryClient } from "@tanstack/react-query";5import { ChevronDown, ChevronRight, FlaskConical, GitBranch, Database, CalendarClock, BookMarked, FileText, CheckSquare, Sparkles, X, Plus, LayoutGrid } from "lucide-react";6import Link from "next/link";7import { api } from "@/lib/client/api";8import { Tabs, TabsList, TabsTrigger, TabsContent, Badge } from "@/components/ui/primitives";9import { Input } from "@/components/ui/input";10import { toast } from "@/components/ui/toast";11import { Select, PAPER_STATUSES, STATUS_TONE } from "./ui";12import { ReferencesTab, refsKey, attKey } from "./ReferencesTab";13import { FilesTab } from "./FilesTab";14import { ExperimentsTab, expKey } from "./ExperimentsTab";15import { TasksTab, taskKey } from "./TasksTab";16import { AITab } from "./AITab";17import { LatexSnippets } from "./LatexSnippets";18import { cn } from "@/lib/utils";1920type Meta = { status?: string; venue?: string; deadline?: string; repoUrl?: string; datasets?: string[]; [k: string]: unknown };21export function ResearchPanel({ pageId }: { pageId: string }) {22  const qc = useQueryClient();23  const { data: page } = useQuery<any>({ queryKey: ["research-page", pageId], queryFn: () => api(`/api/pages/${pageId}`), staleTime: 30_000 });24  const { data: refs = [] } = useQuery<any[]>({ queryKey: refsKey(pageId), queryFn: () => api(`/api/research/references?pageId=${pageId}`) });25  const { data: files = [] } = useQuery<any[]>({ queryKey: attKey(pageId), queryFn: () => api(`/api/attachments?pageId=${pageId}`) });26  const { data: exps = [] } = useQuery<any[]>({ queryKey: expKey(pageId), queryFn: () => api(`/api/research/experiments?pageId=${pageId}`) });27  const { data: tasks = [] } = useQuery<any[]>({ queryKey: taskKey(pageId), queryFn: () => api(`/api/research/tasks?pageId=${pageId}`) });28  const [open, setOpen] = useState(true); const [tab, setTab] = useState("references"); const [meta, setMeta] = useState<Meta>({}); const [dsDraft, setDsDraft] = useState(""); const loaded = useRef(false);29  useEffect(() => { try { const o = localStorage.getItem("research-panel-open"); if (o != null) setOpen(o === "1"); const t = localStorage.getItem(`research-tab-${pageId}`); if (t) setTab(t); } catch {} }, [pageId]);30  useEffect(() => { if (page && !loaded.current) { setMeta((page.properties ?? {}) as Meta); loaded.current = true; } }, [page]);31  const timer = useRef<any>(null);32  /** Merge into the freshest server copy of properties so other editors of `properties` (tags) are not clobbered. */33  async function persist(patch: Partial<Meta>) {34    const next = { ...meta, ...patch }; setMeta(next);35    clearTimeout(timer.current);36    timer.current = setTimeout(async () => {37      try { const fresh = await api(`/api/pages/${pageId}`); const merged = { ...(fresh.properties ?? {}), ...next }; await api(`/api/pages/${pageId}`, { method: "PATCH", json: { properties: merged } }); qc.setQueryData(["research-page", pageId], { ...fresh, properties: merged }); qc.invalidateQueries({ queryKey: ["research-overview"] }); }38      catch (e: any) { toast("Could not save research properties", { description: e.message, tone: "danger" }); }39    }, 500);40  }41  const openTasks = tasks.filter((t) => t.status !== "done").length; const overdue = tasks.filter((t) => t.status !== "done" && t.dueAt && new Date(t.dueAt) < new Date()).length;42  const deadlineSoon = meta.deadline && (new Date(meta.deadline).getTime() - Date.now()) / 864e5 < 14;43  const datasets = Array.isArray(meta.datasets) ? meta.datasets : [];44  return (45    <section className="mt-4 rounded-xl border border-line bg-bg-elev shadow-card">46      <header className="flex flex-wrap items-center gap-2 px-3 py-2">47        <button onClick={() => { setOpen(!open); try { localStorage.setItem("research-panel-open", open ? "0" : "1"); } catch {} }} className="flex h-8 items-center gap-1.5 rounded-md px-1.5 text-[13px] font-medium hover:bg-bg-sunk" aria-expanded={open}>{open ? <ChevronDown className="size-4 text-fg-faint" /> : <ChevronRight className="size-4 text-fg-faint" />}<FlaskConical className="size-4 text-accent" /> Research</button>48        <Select value={meta.status ?? ""} onChange={(e) => persist({ status: e.target.value || undefined })} className={cn("h-8 capitalize", !meta.status && "text-fg-faint")} aria-label="Status"><option value="">status…</option>{PAPER_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}</Select>49        {!open && <span className="flex flex-wrap items-center gap-1 text-[11px] text-fg-faint">{meta.status && <Badge tone={STATUS_TONE[meta.status] ?? "neutral"} className="capitalize">{meta.status}</Badge>}<span>{refs.length} refs</span><span>· {files.length} files</span><span>· {exps.length} exp.</span><span className={overdue ? "text-danger" : ""}>· {openTasks} tasks{overdue ? ` (${overdue} overdue)` : ""}</span>{meta.venue && <span>· {meta.venue}</span>}{meta.deadline && <span className={deadlineSoon ? "text-warning" : ""}>· due {meta.deadline}</span>}</span>}50        <span className="ml-auto flex items-center gap-1"><LatexSnippets compact /><Link href="/research" className="flex h-7 items-center gap-1 rounded-md border border-line bg-bg-elev px-2 text-[12px] font-medium hover:bg-bg-sunk" title="Working papers overview"><LayoutGrid className="size-3.5" /><span className="hidden sm:inline">Overview</span></Link></span>51      </header>52      {open && (53        <div className="border-t border-line px-3 pb-3 pt-2">54          <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">55            <label className="flex min-w-0 flex-col gap-0.5"><span className="text-[11px] font-medium uppercase tracking-wide text-fg-faint">Venue / target journal</span><Input value={meta.venue ?? ""} onChange={(e) => persist({ venue: e.target.value })} placeholder="e.g. Journal of Financial Economics" className="h-8 text-[13px]" /></label>56            <label className="flex min-w-0 flex-col gap-0.5"><span className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-faint"><CalendarClock className="size-3" /> Deadline</span><input type="date" value={meta.deadline ?? ""} onChange={(e) => persist({ deadline: e.target.value || undefined })} className={cn("h-8 rounded-md border border-line bg-bg-elev px-2 text-[13px] shadow-card", deadlineSoon && "border-warning/60")} /></label>57            <label className="flex min-w-0 flex-col gap-0.5"><span className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-faint"><GitBranch className="size-3" /> Git repository</span><div className="flex items-center gap-1"><Input value={meta.repoUrl ?? ""} onChange={(e) => persist({ repoUrl: e.target.value })} placeholder="https://…" className="h-8 text-[12px]" />{meta.repoUrl && <a href={meta.repoUrl} target="_blank" rel="noopener noreferrer" className="shrink-0 text-[11px] text-accent hover:underline">open</a>}</div></label>58            <div className="flex min-w-0 flex-col gap-0.5"><span className="flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-faint"><Database className="size-3" /> Datasets</span><div className="flex min-h-8 flex-wrap items-center gap-1 rounded-md border border-line bg-bg-elev px-1.5 py-0.5 shadow-card">{datasets.map((d) => <span key={d} className="flex max-w-full items-center gap-1 rounded bg-bg-sunk px-1.5 py-0.5 text-[11px]"><a href={/^https?:/.test(d) ? d : undefined} target="_blank" rel="noopener noreferrer" className="truncate hover:text-accent">{d}</a><button onClick={() => persist({ datasets: datasets.filter((x) => x !== d) })} aria-label="Remove"><X className="size-3" /></button></span>)}<input value={dsDraft} onChange={(e) => setDsDraft(e.target.value)} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === ",") && dsDraft.trim()) { e.preventDefault(); persist({ datasets: [...datasets, dsDraft.trim()] }); setDsDraft(""); } }} onBlur={() => { if (dsDraft.trim()) { persist({ datasets: [...datasets, dsDraft.trim()] }); setDsDraft(""); } }} placeholder={datasets.length ? "" : "name or URL, Enter"} className="h-6 min-w-[6rem] flex-1 bg-transparent text-[12px] outline-none placeholder:text-fg-faint" /><Plus className="size-3 text-fg-faint" /></div></div>59          </div>60          <Tabs value={tab} onValueChange={(v) => { setTab(v); try { localStorage.setItem(`research-tab-${pageId}`, v); } catch {} }} className="mt-3">61            <div className="-mx-3 overflow-x-auto px-3 [scrollbar-width:none]"><TabsList className="w-max">62              <TabsTrigger value="references"><BookMarked className="mr-1 size-3.5" /> References <span className="ml-1 text-fg-faint">{refs.length}</span></TabsTrigger>63              <TabsTrigger value="files"><FileText className="mr-1 size-3.5" /> Files <span className="ml-1 text-fg-faint">{files.length}</span></TabsTrigger>64              <TabsTrigger value="experiments"><FlaskConical className="mr-1 size-3.5" /> Experiments <span className="ml-1 text-fg-faint">{exps.length}</span></TabsTrigger>65              <TabsTrigger value="tasks"><CheckSquare className="mr-1 size-3.5" /> Tasks <span className={cn("ml-1", overdue ? "text-danger" : "text-fg-faint")}>{openTasks}</span></TabsTrigger>66              <TabsTrigger value="ai"><Sparkles className="mr-1 size-3.5 text-ai" /> AI</TabsTrigger>67            </TabsList></div>68            <TabsContent value="references" className="mt-3 outline-none"><ReferencesTab pageId={pageId} /></TabsContent>69            <TabsContent value="files" className="mt-3 outline-none"><FilesTab pageId={pageId} /></TabsContent>70            <TabsContent value="experiments" className="mt-3 outline-none"><ExperimentsTab pageId={pageId} /></TabsContent>71            <TabsContent value="tasks" className="mt-3 outline-none"><TasksTab pageId={pageId} /></TabsContent>72            <TabsContent value="ai" className="mt-3 outline-none"><AITab pageId={pageId} pageTitle={page?.title ?? "Working paper"} /></TabsContent>73          </Tabs>74        </div>75      )}76    </section>77  );78}79
Files · main
Menu

Theme