Implement citations

This commit is contained in:
Adrian Rumpold
2025-05-21 12:29:12 +02:00
parent 7a0355aebf
commit e31ed72957
9 changed files with 216 additions and 28 deletions

View File

@@ -1,18 +1,21 @@
import { useState } from "react"; import { useState } from "react";
import AlphabeticalFilter from "./components/AlphabeticalFilter/AlphabeticalFilter"; import AlphabeticalFilter from "./components/AlphabeticalFilter/AlphabeticalFilter";
import CitationEntry from "./components/CitationEntry/CitationEntry";
import GlossaryEntry from "./components/GlossaryEntry/GlossaryEntry"; import GlossaryEntry from "./components/GlossaryEntry/GlossaryEntry";
import StringFilter from "./components/StringFilter/StringFilter"; import StringFilter from "./components/StringFilter/StringFilter";
import useGlossary from "./hooks/useGlossary"; import useGlossary from "./hooks/useGlossary";
function App() { function App() {
const { data: glossary, error, isPending } = useGlossary(); const { glossary, citations, isLoading, isError } = useGlossary();
const [alphabeticalFilter, setAlphabeticalFilter] = useState(""); const [alphabeticalFilter, setAlphabeticalFilter] = useState("");
const [stringFilter, setStringFilter] = useState(""); const [stringFilter, setStringFilter] = useState("");
const [selectedCitation, setSelectedCitation] = useState<string | null>(null);
if (isPending) return <div>Loading...</div>; if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>; if (isError) return <div>Error while fetching</div>;
const entries = glossary?.definitions const entries = glossary!
.filter((entry) => { .filter((entry) => {
if (!stringFilter) { if (!stringFilter) {
return true; return true;
@@ -34,9 +37,21 @@ function App() {
<StringFilter onChange={setStringFilter} /> <StringFilter onChange={setStringFilter} />
<AlphabeticalFilter onChange={setAlphabeticalFilter} /> <AlphabeticalFilter onChange={setAlphabeticalFilter} />
{entries.length} entries found {entries.length} entries found
{selectedCitation && (
<CitationEntry
citation={
citations!.find((citation) => citation.key === selectedCitation)!
}
onClose={() => setSelectedCitation(null)}
/>
)}
<dl> <dl>
{entries.map((term) => ( {entries.map((term) => (
<GlossaryEntry term={term} key={term.term} /> <GlossaryEntry
term={term}
key={term.term}
onSelectCitation={setSelectedCitation}
/>
))} ))}
</dl> </dl>
</div> </div>

View File

@@ -0,0 +1,16 @@
.citation-entry {
display: flex;
flex-direction: column;
padding: 10px;
margin-bottom: 10px;
gap: 1rem;
max-width: 48ch;
.title {
font-weight: bold;
}
.authors {
font-style: italic;
}
}

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef } from "react";
import type { Citation } from "../../lib/nist-api";
import "./CitationEntry.css";
type CitationEntryProps = {
citation?: Citation;
onClose?: () => void;
};
export default function CitationEntry({
citation,
onClose,
}: CitationEntryProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) {
return;
}
if (citation) {
ref.current.showPopover();
}
}, [citation]);
if (!citation) {
return null;
}
const richTitle = citation.year
? `${citation.title} (${citation.year})`
: citation.title;
const onToggle = (e: React.ToggleEvent) => {
if (e.newState === "closed") {
onClose?.();
}
};
return (
<div
popover="auto"
ref={ref}
className="citation-modal"
onToggle={onToggle}
>
<div className="citation-entry">
<div className="title">
{citation.url ? (
<a href={citation.url} target="_blank">
{richTitle}
</a>
) : (
richTitle
)}
</div>
{citation.authors && <div className="authors">{citation.authors}</div>}
{citation.publication && <div>{citation.publication}</div>}
{citation.issue && <div>Issue {citation.issue}</div>}
{citation.volume && <div>Volume {citation.volume}</div>}
{citation.pages && <div>{citation.pages}</div>}
</div>
</div>
);
}

View File

@@ -19,3 +19,13 @@ dd + dt {
dt + dd { dt + dd {
margin-top: 1rem; margin-top: 1rem;
} }
.citation {
display: block;
cursor: pointer;
font-size: 0.8em;
color: var(--color-text-alt);
}
.citation::after {
content: " ↗";
}

View File

@@ -3,14 +3,25 @@ import "./GlossaryEntry.css";
type GlossaryEntryProps = { type GlossaryEntryProps = {
term: GlossaryTerm; term: GlossaryTerm;
onSelectCitation?: (citationKey: string) => void;
}; };
function GlossaryEntry({ term }: GlossaryEntryProps) { function GlossaryEntry({ term, onSelectCitation }: GlossaryEntryProps) {
return ( return (
<> <>
<dt key={term.term}>{term.term}</dt> <dt key={term.term}>{term.term}</dt>
{term.definitions.map((def, index) => ( {term.definitions.map((def, index) => (
<dd key={term.term + index}>{def.definition}</dd> <dd key={term.term + index}>
{def.definition}
<a
className="citation"
onClick={() =>
onSelectCitation && onSelectCitation(def.citationKey)
}
>
{def.citationKey}
</a>
</dd>
))} ))}
</> </>
); );

View File

@@ -1,10 +1,24 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchGlossary } from "../lib/nist-api"; import { fetchCitations, fetchDefinitions } from "../lib/nist-api";
const useGlossary = () => const useGlossary = () => {
useQuery({ const definitionsQuery = useQuery({
queryKey: ["glossary"], queryKey: ["definitions"],
queryFn: fetchGlossary, queryFn: fetchDefinitions,
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 1000 * 60 * 60, // 1 hour
}); });
const citationsQuery = useQuery({
queryKey: ["citations"],
queryFn: fetchCitations,
staleTime: 1000 * 60 * 60, // 1 hour
});
return {
glossary: definitionsQuery.data,
citations: citationsQuery.data,
isLoading: definitionsQuery.isLoading || citationsQuery.isLoading,
isError: definitionsQuery.isError || citationsQuery.isError,
};
};
export default useGlossary; export default useGlossary;

View File

@@ -25,7 +25,7 @@ a:hover {
} }
body { body {
margin: 0; margin: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
place-items: center; place-items: center;
@@ -34,8 +34,7 @@ body {
} }
footer { footer {
padding: 1rem; margin-top: 2rem;
margin: 0 2rem;
font-size: 0.6rem; font-size: 0.6rem;
} }
@@ -44,7 +43,6 @@ footer {
flex-direction: column; flex-direction: column;
place-items: center; place-items: center;
width: 100%; width: 100%;
padding: 2rem 0;
} }
button { button {

View File

@@ -30,23 +30,58 @@ export type Glossary = {
citations: Citation[]; citations: Citation[];
}; };
const glossaryUrl = const baseUrl =
"https://docs.google.com/spreadsheets/d/e/2PACX-1vTRBYglcOtgaMrdF11aFxfEY3EmB31zslYI4q2_7ZZ8z_1lKm7OHtF0t4xIsckuogNZ3hRZAaDQuv_K/pub?output=csv"; "https://docs.google.com/spreadsheets/d/e/2PACX-1vTRBYglcOtgaMrdF11aFxfEY3EmB31zslYI4q2_7ZZ8z_1lKm7OHtF0t4xIsckuogNZ3hRZAaDQuv_K/pub?output=csv";
const glossaryGid = "0";
const citationGid = "2053825396";
export const fetchGlossary = async () => { const makeClient = () => {
const resp = await new Axios().get(glossaryUrl); const client = new Axios({
const data = resp.data; baseURL: baseUrl,
return parseGlossary(data); });
return client;
}; };
const parseGlossary = (data: string): Glossary => { export const fetchCitations = async () => {
const parsed = Papa.parse<string[]>(data, { const client = makeClient();
const resp = await client.get(baseUrl, {
params: {
gid: citationGid,
},
});
return parseCitations(resp.data);
};
export const fetchDefinitions = async () => {
const client = makeClient();
const resp = await client.get(baseUrl, {
params: {
gid: glossaryGid,
},
});
return parseDefinitions(resp.data);
};
export const parseGlossary = (
glossaryData: string,
citationData: string
): Glossary => {
const glossary: Glossary = {
definitions: parseDefinitions(glossaryData),
citations: parseCitations(citationData),
};
return glossary;
};
const parseDefinitions = (glossaryData: string): GlossaryTerm[] => {
const terms: GlossaryTerm[] = [];
const parsed = Papa.parse<string[]>(glossaryData, {
delimiter: ",", delimiter: ",",
header: false, header: false,
dynamicTyping: true, dynamicTyping: true,
skipEmptyLines: true, skipEmptyLines: true,
}); });
const glossary: Glossary = { definitions: [], citations: [] };
for (const record of parsed.data.slice(1)) { for (const record of parsed.data.slice(1)) {
const fields = record.map((field) => const fields = record.map((field) =>
@@ -70,7 +105,7 @@ const parseGlossary = (data: string): Glossary => {
const legalDefinition = fields[fields.length - 1]; const legalDefinition = fields[fields.length - 1];
if (term && definitions.length > 0) { if (term && definitions.length > 0) {
glossary.definitions.push({ terms.push({
term, term,
definitions, definitions,
relatedTerms, relatedTerms,
@@ -78,6 +113,33 @@ const parseGlossary = (data: string): Glossary => {
}); });
} }
} }
return terms;
return glossary; };
const parseCitations = (citationData: string): Citation[] => {
const citations: Citation[] = [];
const parsed = Papa.parse<string[]>(citationData, {
delimiter: ",",
header: false,
dynamicTyping: true,
skipEmptyLines: true,
});
for (const record of parsed.data.slice(1)) {
const fields = record;
const citation: Citation = {
key: fields[0],
title: fields[1],
authors: fields[2],
publication: fields[3],
volume: fields[4],
issue: fields[5],
pages: fields[6],
year: fields[7],
url: fields[8],
};
citations.push(citation);
}
return citations;
}; };

View File

@@ -4,6 +4,7 @@
--color-background: #242424; --color-background: #242424;
--color-background-alt: #1e1e1e; --color-background-alt: #1e1e1e;
--color-text: #ffffff; --color-text: #ffffff;
--color-text-alt: #aaaaaa;
--color-accent: #646cff; --color-accent: #646cff;
--color-accent-hover: #535bf2; --color-accent-hover: #535bf2;
--color-border: #444444; --color-border: #444444;
@@ -14,6 +15,7 @@
--color-background: #ffffff; --color-background: #ffffff;
--color-background-alt: #f5f5f5; --color-background-alt: #f5f5f5;
--color-text: #213547; --color-text: #213547;
--color-text-alt: #000000;
--color-accent: #747bff; --color-accent: #747bff;
--color-accent-hover: #646cff; --color-accent-hover: #646cff;
--color-border: #d9d9d9; --color-border: #d9d9d9;