"use client"; import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BookMarked, Plus, Upload, Download, MoreHorizontal, Copy, ExternalLink, Pencil, Paperclip, ShieldCheck, Trash2, Search } from "lucide-react"; import { api } from "@/lib/client/api"; import { Button } from "@/components/ui/button"; import { Input, Textarea } from "@/components/ui/input"; import { Dialog, DialogContent, Popover, PopoverTrigger, PopoverContent, Menu, MenuTrigger, MenuContent, MenuItem, MenuSeparator, Spinner, Empty } from "@/components/ui/primitives"; import { toast } from "@/components/ui/toast"; import { Field, Select, VerifiedBadge, copy, shortAuthors } from "./ui"; import { cn } from "@/lib/utils"; const ENTRY_TYPES = ["article", "book", "incollection", "inproceedings", "techreport", "phdthesis", "mastersthesis", "unpublished", "misc"]; type Ref = any; export const refsKey = (pageId: string) => ["research-refs", pageId]; export const attKey = (pageId: string) => ["attachments", pageId]; export function ReferencesTab({ pageId }: { pageId: string }) { const qc = useQueryClient(); const { data: refs = [], isLoading } = useQuery({ queryKey: refsKey(pageId), queryFn: () => api(`/api/research/references?pageId=${pageId}`) }); const { data: files = [] } = useQuery({ queryKey: attKey(pageId), queryFn: () => api(`/api/attachments?pageId=${pageId}`) }); const [q, setQ] = useState(""); const [editing, setEditing] = useState(null); const [importOpen, setImportOpen] = useState(false); const refresh = () => { qc.invalidateQueries({ queryKey: refsKey(pageId) }); qc.invalidateQueries({ queryKey: ["research-overview"] }); }; const list = refs.filter((r) => !q || `${r.citeKey} ${r.title} ${(r.authors ?? []).join(" ")} ${r.journal ?? ""} ${r.year ?? ""}`.toLowerCase().includes(q.toLowerCase())); async function del(r: Ref) { if (!confirm(`Delete reference ${r.citeKey}?`)) return; await api(`/api/research/references/${r.id}`, { method: "DELETE" }); toast("Reference deleted"); refresh(); } async function verify(r: Ref) { try { await api(`/api/research/references/${r.id}?action=verify`, { method: "POST" }); toast("Verified with Crossref", { tone: "success" }); refresh(); } catch (e: any) { toast("Verification failed", { description: e.message, tone: "danger" }); } } async function attach(r: Ref, attachmentId: string | null) { await api(`/api/research/references/${r.id}?action=attach`, { method: "POST", json: { attachmentId } }); toast(attachmentId ? "PDF attached" : "PDF detached"); refresh(); } const fileOf = (id: string | null) => files.find((f) => f.id === id); const Actions = ({ r }: { r: Ref }) => ( copy(`\\cite{${r.citeKey}}`, `\\cite{${r.citeKey}} copied`)}> Copy \cite{"{"}{r.citeKey}{"}"} copy(r.bibtex ?? "", "BibTeX copied")}> Copy BibTeX {r.doi && window.open(`https://doi.org/${r.doi}`, "_blank")}> Open DOI} {r.attachmentId && window.open(`/api/files/${r.attachmentId}`, "_blank")}> Open PDF} setEditing(r)}> Edit {r.doi && r.verified !== "doi_verified" && verify(r)}> Verify via Crossref} {files.length > 0 && } {files.filter((f) => f.id !== r.attachmentId).slice(0, 8).map((f) => attach(r, f.id)}> Attach {f.filename})} {r.attachmentId && attach(r, null)}> Detach PDF} del(r)}> Delete ); return (
setQ(e.target.value)} placeholder="Filter references" className="h-8 pl-7 text-[13px]" />
{isLoading ?
: !refs.length ? } title="No references yet" hint="Add by DOI, enter one manually or import a .bib file. Cite with \cite{key} in the page." /> : ( <> {list.map((r) => )}
KeyAuthorsYearTitleJournalDOIStatus
{r.attachmentId && } {shortAuthors(r.authors)} {r.year ?? "—"} {r.title} {r.journal ?? "—"} {r.doi ? {r.doi.length > 24 ? r.doi.slice(0, 22) + "…" : r.doi} : "—"}
{list.map((r) =>
{r.attachmentId && }
{r.title}
{shortAuthors(r.authors)} · {r.year ?? "n.d."}{r.journal ? ` · ${r.journal}` : ""}
{r.doi && {r.doi}}
)}
{!list.length &&
No reference matches “{q}”.
}
{refs.length} reference{refs.length > 1 ? "s" : ""} · {refs.filter((r) => r.verified === "doi_verified").length} DOI-verified · {refs.filter((r) => r.attachmentId).length} with PDF
)} { if (!o) setEditing(null); }}>{editing !== null && { setEditing(null); refresh(); }} />} { setImportOpen(false); refresh(); }} />
); } function DoiAdd({ pageId, onDone }: { pageId: string; onDone: () => void }) { const [open, setOpen] = useState(false); const [doi, setDoi] = useState(""); const [busy, setBusy] = useState(false); async function add() { const d = doi.trim(); if (!d || busy) return; setBusy(true); try { const r = await api("/api/research/references/doi", { method: "POST", json: { doi: d, pageId } }); toast(`Added ${r.citeKey}`, { description: r.title, tone: "success" }); setDoi(""); setOpen(false); onDone(); } catch (e: any) { toast("DOI lookup failed", { description: e.message, tone: "danger" }); } finally { setBusy(false); } } return (
Add from Crossref
setDoi(e.target.value)} placeholder="10.1016/j.jfineco.2019.09.005" className="h-8 font-mono text-[12px]" onKeyDown={(e) => { if (e.key === "Enter") add(); }} />
Metadata is fetched from api.crossref.org and the entry is marked “DOI verified”.
); } function RefForm({ pageId, initial, files, onDone }: { pageId: string; initial: Ref | null; files: any[]; onDone: () => void }) { const f = initial?.fields ?? {}; const [v, setV] = useState({ citeKey: initial?.citeKey ?? "", entryType: initial?.entryType ?? "article", title: initial?.title ?? "", authors: (initial?.authors ?? []).join("\n"), year: initial?.year ? String(initial.year) : "", journal: initial?.journal ?? "", doi: initial?.doi ?? "", url: initial?.url ?? "", volume: f.volume ?? "", number: f.number ?? "", pages: f.pages ?? "", abstract: f.abstract ?? "", attachmentId: initial?.attachmentId ?? "" }); const [busy, setBusy] = useState(false); const set = (k: keyof typeof v) => (e: React.ChangeEvent) => setV({ ...v, [k]: e.target.value }); async function save() { if (!v.title.trim()) { toast("Title is required", { tone: "danger" }); return; } setBusy(true); const body = { pageId, citeKey: v.citeKey.trim() || null, entryType: v.entryType, title: v.title.trim(), authors: v.authors.split(/\n|;| and /).map((a: string) => a.trim()).filter(Boolean), year: v.year ? Number(v.year) : null, journal: v.journal.trim() || null, doi: v.doi.trim() || null, url: v.url.trim() || null, fields: { volume: v.volume, number: v.number, pages: v.pages, abstract: v.abstract }, attachmentId: v.attachmentId || null }; try { if (initial) await api(`/api/research/references/${initial.id}`, { method: "PATCH", json: { ...body, citeKey: body.citeKey ?? undefined } }); else await api("/api/research/references", { method: "POST", json: { ...body, verified: "user" } }); toast(initial ? "Reference updated" : "Reference added", { tone: "success" }); onDone(); } catch (e: any) { toast("Save failed", { description: e.message, tone: "danger" }); } finally { setBusy(false); } } return (
{ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save(); }}>