carbon-tokenization / frontend /src /editor /CitationPanel.tsx
tfrere's picture
tfrere HF Staff
chore: initial commit
561e6f0
Raw
History Blame Contribute Delete
6.6 kB
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);
}
/**
* Single smart-field citation panel.
* Auto-detects DOI / URL / BibTeX from the input.
* Library is always visible below.
*/
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}>
&times;
</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;
}