| import { useState, useCallback, useEffect } from "react"; |
| import type { Editor } from "@tiptap/core"; |
| import type * as Y from "yjs"; |
|
|
| interface CitationPanelProps { |
| editor: Editor; |
| citationsMap: Y.Map<any>; |
| onClose: () => void; |
| } |
|
|
| function isBibTeX(input: string): boolean { |
| const trimmed = input.trim(); |
| return trimmed.startsWith("@") && /\{[\s\S]*\}/.test(trimmed); |
| } |
|
|
| |
| |
| |
| |
| |
| export function CitationPanel({ |
| editor, |
| citationsMap, |
| onClose, |
| }: CitationPanelProps) { |
| const [input, setInput] = useState(""); |
| const [loading, setLoading] = useState(false); |
| const [error, setError] = useState(""); |
| const [libraryEntries, setLibraryEntries] = useState< |
| { key: string; entry: any }[] |
| >([]); |
|
|
| useEffect(() => { |
| const refresh = () => setLibraryEntries(getLibraryEntries(citationsMap)); |
| refresh(); |
| citationsMap.observe(refresh); |
| return () => citationsMap.unobserve(refresh); |
| }, [citationsMap]); |
|
|
| const ensureBibliography = useCallback(() => { |
| let hasBibliography = false; |
| editor.state.doc.descendants((node) => { |
| if (node.type.name === "bibliography") hasBibliography = true; |
| }); |
| if (!hasBibliography) { |
| const endPos = editor.state.doc.content.size; |
| editor |
| .chain() |
| .insertContentAt(endPos, [ |
| { type: "paragraph" }, |
| { type: "bibliography" }, |
| ]) |
| .run(); |
| } |
| }, [editor]); |
|
|
| const insertCitation = useCallback( |
| (entry: any) => { |
| const key = |
| entry["citation-key"] || entry.id || generateKey(entry); |
| citationsMap.set(key, entry); |
| editor.chain().focus().insertCitation(key).run(); |
| ensureBibliography(); |
| onClose(); |
| }, |
| [editor, citationsMap, onClose, ensureBibliography], |
| ); |
|
|
| const insertExisting = useCallback( |
| (key: string) => { |
| editor.chain().focus().insertCitation(key).run(); |
| ensureBibliography(); |
| onClose(); |
| }, |
| [editor, onClose, ensureBibliography], |
| ); |
|
|
| const resolveInput = useCallback(async () => { |
| if (!input.trim()) return; |
| setLoading(true); |
| setError(""); |
| try { |
| const bibtex = isBibTeX(input); |
| const endpoint = bibtex |
| ? "/api/citations/import-bib" |
| : "/api/citations/resolve"; |
| const body = bibtex ? { bibtex: input } : { input }; |
|
|
| const res = await fetch(endpoint, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(body), |
| }); |
| const data = await res.json(); |
|
|
| if (!res.ok) { |
| setError(data.error || "Resolution failed"); |
| return; |
| } |
|
|
| if (data.entries?.length >= 1) { |
| data.entries.forEach((entry: any) => { |
| const key = |
| entry["citation-key"] || entry.id || generateKey(entry); |
| citationsMap.set(key, entry); |
| }); |
| insertCitation(data.entries[0]); |
| } |
| } catch (err: any) { |
| setError(err.message || "Network error"); |
| } finally { |
| setLoading(false); |
| } |
| }, [input, insertCitation, citationsMap]); |
|
|
| return ( |
| <div className="citation-panel-overlay" onClick={onClose}> |
| <div className="citation-panel" onClick={(e) => e.stopPropagation()}> |
| <div className="citation-panel-header"> |
| <h3>Add reference</h3> |
| <button className="citation-panel-close" onClick={onClose}> |
| × |
| </button> |
| </div> |
| |
| <div className="citation-panel-body"> |
| <textarea |
| className="citation-input citation-smart-input" |
| placeholder={"Paste a DOI, URL, or BibTeX entry...\ne.g. 10.1038/s41586-020-2649-2"} |
| value={input} |
| onChange={(e) => setInput(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === "Enter" && !e.shiftKey && !isBibTeX(input)) { |
| e.preventDefault(); |
| resolveInput(); |
| } |
| }} |
| rows={3} |
| autoFocus |
| /> |
| |
| {error && <p className="citation-error">{error}</p>} |
| |
| <button |
| className="citation-resolve-btn" |
| onClick={resolveInput} |
| disabled={loading || !input.trim()} |
| > |
| {loading ? "Resolving..." : "Resolve & Add"} |
| </button> |
| |
| {libraryEntries.length > 0 && ( |
| <> |
| <div className="citation-library-divider"> |
| <span>Your references ({libraryEntries.length})</span> |
| </div> |
| <div className="citation-library-list"> |
| {libraryEntries.map(({ key, entry }) => ( |
| <div key={key} className="citation-library-item"> |
| <div className="citation-library-info"> |
| <span className="citation-library-meta"> |
| {formatAuthorsShort(entry)}{" "} |
| {entry.issued?.["date-parts"]?.[0]?.[0] || ""} |
| </span> |
| <span className="citation-library-title"> |
| {entry.title || key} |
| </span> |
| </div> |
| <button |
| className="citation-library-insert" |
| onClick={() => insertExisting(key)} |
| title="Insert citation" |
| > |
| + |
| </button> |
| </div> |
| ))} |
| </div> |
| </> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function generateKey(entry: any): string { |
| const firstAuthor = entry.author?.[0]?.family || "unknown"; |
| const year = entry.issued?.["date-parts"]?.[0]?.[0] || "nd"; |
| return `${firstAuthor.toLowerCase()}${year}`.replace(/[^a-z0-9]/gi, ""); |
| } |
|
|
| function formatAuthorsShort(entry: any): string { |
| const authors = entry.author; |
| if (!authors?.length) return ""; |
| const first = authors[0].family || ""; |
| if (authors.length === 1) return first; |
| if (authors.length === 2) return `${first} & ${authors[1].family || ""}`; |
| return `${first} et al.`; |
| } |
|
|
| function getLibraryEntries(map: Y.Map<any>): { key: string; entry: any }[] { |
| const result: { key: string; entry: any }[] = []; |
| map.forEach((entry, key) => { |
| result.push({ key, entry }); |
| }); |
| result.sort((a, b) => { |
| const aName = a.entry.author?.[0]?.family || a.key; |
| const bName = b.entry.author?.[0]?.family || b.key; |
| return aName.localeCompare(bName); |
| }); |
| return result; |
| } |
|
|