| import { useEffect, useState, useCallback, useRef } from "react"; |
| import type { Editor as TiptapEditor } from "@tiptap/core"; |
| import type { FrontmatterStore, FrontmatterData } from "./frontmatter/frontmatter-store"; |
|
|
| function extractYear(dateStr?: string): number | undefined { |
| if (!dateStr) return undefined; |
| const d = new Date(dateStr); |
| if (!Number.isNaN(d.getTime())) return d.getFullYear(); |
| const m = dateStr.match(/(19|20)\d{2}/); |
| return m ? Number(m[0]) : undefined; |
| } |
|
|
| function buildCitationText(data: FrontmatterData): string { |
| const names = data.authors.map((a) => a.name).filter(Boolean); |
| const year = extractYear(data.published); |
| const title = (data.title || "Untitled").replace(/\s+/g, " ").trim(); |
| return `${names.join(", ")}${year ? ` (${year})` : ""}. "${title}".`; |
| } |
|
|
| function buildBibtex(data: FrontmatterData): string { |
| const names = data.authors.map((a) => a.name).filter(Boolean); |
| const title = (data.title || "Untitled").replace(/\s+/g, " ").trim(); |
| const year = extractYear(data.published); |
| const keyAuthor = (names[0] || "article").split(/\s+/).slice(-1)[0].toLowerCase(); |
| const keyTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, ""); |
| const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`; |
| const parts = [` title={${title}}`, ` author={${names.join(" and ")}}`]; |
| if (year) parts.push(` year={${year}}`); |
| if (data.doi) parts.push(` doi={${data.doi}}`); |
| return `@misc{${bibKey},\n${parts.join(",\n")}\n}`; |
| } |
|
|
| interface FootnoteItem { |
| index: number; |
| content: string; |
| } |
|
|
| function collectFootnotes(editor: TiptapEditor): FootnoteItem[] { |
| const items: FootnoteItem[] = []; |
| let i = 0; |
| editor.state.doc.descendants((node) => { |
| if (node.type.name === "footnote") { |
| i++; |
| items.push({ index: i, content: (node.attrs.content as string) || "" }); |
| } |
| }); |
| return items; |
| } |
|
|
| function getBibliographyHtml(editor: TiptapEditor): string { |
| let html = ""; |
| editor.state.doc.descendants((node) => { |
| if (node.type.name === "bibliography" && node.attrs.renderedHtml) { |
| html = node.attrs.renderedHtml as string; |
| } |
| }); |
| return html; |
| } |
|
|
| interface EditorFooterProps { |
| store: FrontmatterStore | null; |
| editor: TiptapEditor | null; |
| } |
|
|
| export function EditorFooter({ store, editor }: EditorFooterProps) { |
| const [data, setData] = useState<FrontmatterData | null>(null); |
| const [biblioHtml, setBiblioHtml] = useState(""); |
| const [footnotes, setFootnotes] = useState<FootnoteItem[]>([]); |
|
|
| useEffect(() => { |
| if (!store) return; |
| const sync = () => setData(store.getAll()); |
| sync(); |
| return store.observe(sync); |
| }, [store]); |
|
|
| const refreshEditorData = useCallback(() => { |
| if (!editor) return; |
| setBiblioHtml(getBibliographyHtml(editor)); |
| setFootnotes(collectFootnotes(editor)); |
| }, [editor]); |
|
|
| const footerTimerRef = useRef(0); |
| useEffect(() => { |
| if (!editor) return; |
| refreshEditorData(); |
| const debouncedRefresh = () => { |
| clearTimeout(footerTimerRef.current); |
| footerTimerRef.current = window.setTimeout(refreshEditorData, 500); |
| }; |
| editor.on("update", debouncedRefresh); |
| return () => { |
| editor.off("update", debouncedRefresh); |
| clearTimeout(footerTimerRef.current); |
| }; |
| }, [editor, refreshEditorData]); |
|
|
| if (!data) return null; |
|
|
| const citation = buildCitationText(data); |
| const bibtex = buildBibtex(data); |
|
|
| return ( |
| <footer className="footer"> |
| <div className="footer-inner"> |
| <section className="citation-block"> |
| <p className="footer-heading" role="heading" aria-level={2}>Citation</p> |
| <p>For attribution in academic contexts, please cite this work as</p> |
| <pre className="citation short">{citation}</pre> |
| <p>BibTeX citation</p> |
| <pre className="citation long">{bibtex}</pre> |
| </section> |
| |
| {data.doi && ( |
| <section className="doi-block"> |
| <p className="footer-heading" role="heading" aria-level={2}>DOI</p> |
| <p> |
| <a href={`https://doi.org/${data.doi}`} target="_blank" rel="noopener noreferrer"> |
| {data.doi} |
| </a> |
| </p> |
| </section> |
| )} |
| |
| {data.licence && ( |
| <section className="reuse-block"> |
| <p className="footer-heading" role="heading" aria-level={2}>Reuse</p> |
| <p dangerouslySetInnerHTML={{ __html: data.licence }} /> |
| </section> |
| )} |
| |
| {biblioHtml && ( |
| <section className="references-block"> |
| <p className="footer-heading" role="heading" aria-level={2}>References</p> |
| <div |
| className="bibliography-content" |
| dangerouslySetInnerHTML={{ __html: biblioHtml }} |
| /> |
| </section> |
| )} |
| |
| {footnotes.length > 0 && ( |
| <section className="references-block"> |
| <p className="footer-heading" role="heading" aria-level={2}>Footnotes</p> |
| <ol className="footnotes-list"> |
| {footnotes.map((fn) => ( |
| <li key={fn.index} id={`fn-${fn.index}`}> |
| <p>{fn.content || "Empty footnote"}</p> |
| </li> |
| ))} |
| </ol> |
| </section> |
| )} |
| |
| <div className="template-credit"> |
| <p> |
| Made with ❤️ with{" "} |
| <a href="https://huggingface.co/spaces/tfrere/research-article-template-editor" target="_blank" rel="noopener noreferrer"> |
| research article template editor |
| </a> |
| </p> |
| </div> |
| </div> |
| </footer> |
| ); |
| } |
|
|