Hierarchical TOC + routing

This commit is contained in:
Adrian Rumpold
2025-04-23 13:27:54 +02:00
parent 6dcf39dc58
commit 2165ce0d5b
11 changed files with 202 additions and 87 deletions

View File

@@ -11,7 +11,8 @@
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-query-devtools": "^5.74.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-router-dom": "^7.5.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
@@ -1934,6 +1935,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2905,6 +2915,45 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.1.tgz",
"integrity": "sha512-/jjU3fcYNd2bwz9Q0xt5TwyiyoO8XjSEFXJY4O/lMAlkGTHWuHRAbR9Etik+lSDqMC7A7mz3UlXzgYT6Vl58sA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.1.tgz",
"integrity": "sha512-5DPSPc7ENrt2tlKPq0FtpG80ZbqA9aIKEyqX6hSNJDlol/tr6iqCK4crqdsusmOSSotq6zDsn0y3urX9TuTNmA==",
"license": "MIT",
"dependencies": {
"react-router": "7.5.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3006,6 +3055,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3136,6 +3191,12 @@
"typescript": ">=4.8.4" "typescript": ">=4.8.4"
} }
}, },
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -13,7 +13,8 @@
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-query-devtools": "^5.74.6", "@tanstack/react-query-devtools": "^5.74.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"react-router-dom": "^7.5.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",

View File

@@ -2,16 +2,20 @@ import { useState } from "react";
import Panel from "./components/Panel"; import Panel from "./components/Panel";
import { useQueries } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import "./App.css"; import "./App.css";
import ArticleSelector from "./components/ArticleSelector"; import ArticleSelector from "./components/ArticleSelector";
import CelexSelector from "./components/CelexSelector";
import TOC from "./components/TOC"; import TOC from "./components/TOC";
import { getArticleIds, getToc } from "./lib/api"; import { getArticleIds, getToc } from "./lib/api";
import { Language } from "./lib/types"; import { Language } from "./lib/types";
function App() { type Props = {
const [celexId, setCelexId] = useState("32024R1689"); celexId: string;
const [selectedArticle, setSelectedArticle] = useState<number>(1); articleId: number;
};
function App({ celexId, articleId }: Props) {
const navigate = useNavigate();
const [numPanels, setNumPanels] = useState(1); const [numPanels, setNumPanels] = useState(1);
const [selectedParagraphId, setSelectedParagraphId] = useState<string | null>( const [selectedParagraphId, setSelectedParagraphId] = useState<string | null>(
null null
@@ -35,10 +39,10 @@ function App() {
const error = results.find((result) => result.isError); const error = results.find((result) => result.isError);
if (isPending) { if (isPending) {
return <div>Loading...</div>; return <div className="panel">Loading...</div>;
} }
if (error) { if (error) {
return <div>Error: {error.error?.message}</div>; return <div className="panel">Error: {error.error?.message}</div>;
} }
const examples = [ const examples = [
@@ -56,9 +60,7 @@ function App() {
id="examples" id="examples"
value={celexId} value={celexId}
onChange={(e) => { onChange={(e) => {
setSelectedArticle(1); navigate(`/${e.target.value}`);
setSelectedParagraphId(null);
setCelexId(e.currentTarget.value);
}} }}
> >
{examples.map((example) => ( {examples.map((example) => (
@@ -68,11 +70,11 @@ function App() {
))} ))}
</select> </select>
</div> </div>
<CelexSelector defaultId={celexId} onSelected={setCelexId} /> {/* <CelexSelector defaultId={celexId} onSelected={setCelexId} /> */}
<ArticleSelector <ArticleSelector
articleIds={results[0].data!} articleIds={results[0].data!}
selectedId={selectedArticle} selectedId={articleId}
onSelected={setSelectedArticle} onSelected={(id) => navigate(`/${celexId}/articles/${id}`)}
/> />
<button onClick={() => setNumPanels((prev) => prev + 1)}> <button onClick={() => setNumPanels((prev) => prev + 1)}>
Add Panel Add Panel
@@ -81,8 +83,8 @@ function App() {
<div className="panel-container"> <div className="panel-container">
<TOC <TOC
toc={results[1].data!} toc={results[1].data!}
selectedArticleId={selectedArticle} selectedArticleId={articleId}
onArticleSelected={setSelectedArticle} onArticleSelected={(id) => navigate(`/${celexId}/articles/${id}`)}
/> />
{Array.from({ length: numPanels }, (_, index) => ( {Array.from({ length: numPanels }, (_, index) => (
<Panel <Panel
@@ -91,7 +93,7 @@ function App() {
language={ language={
Object.values(Language)[index % Object.values(Language).length] Object.values(Language)[index % Object.values(Language).length]
} }
articleId={selectedArticle} articleId={articleId}
selectedParagraphId={selectedParagraphId || undefined} selectedParagraphId={selectedParagraphId || undefined}
onParagraphSelected={setSelectedParagraphId} onParagraphSelected={setSelectedParagraphId}
/> />

13
frontend/src/MainView.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { useParams } from "react-router-dom";
import App from "./App";
function MainView() {
const { celexId, articleId } = useParams();
if (!celexId) {
return <div>Error: No CELEX ID provided</div>;
}
return (
<App celexId={celexId} articleId={articleId ? parseInt(articleId) : 1} />
);
}
export default MainView;

View File

@@ -1,5 +1,4 @@
import { Language } from "../lib/types"; import { Language } from "../lib/types";
import "./LanguageSwitcher.css"; import "./LanguageSwitcher.css";
function LanguageSwitcher({ function LanguageSwitcher({

View File

View File

@@ -8,22 +8,23 @@
min-width: 0; min-width: 0;
} }
transition: width 0.3s ease-in-out;
overflow: scroll; overflow: scroll;
max-height: 100vh; max-height: 100vh;
.division-list { .toc-division {
list-style: none; margin-block: 0.5rem;
cursor: pointer;
}
ul { ul {
margin-block: 1rem; padding: 1rem;
} list-style-type: disc;
} }
.selected { .selected {
font-weight: bold; font-weight: bold;
} }
.article { .article {
cursor: pointer; cursor: pointer;
} }

View File

@@ -1,19 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Division } from "../lib/types";
import "./TOC.css"; import "./TOC.css";
type Article = {
type: "article";
id: number;
title: string;
subtitle: string;
};
type Division = {
type: "division";
title: string;
subtitle: string;
articles: Article[];
};
type TOC = Division[]; type TOC = Division[];
type TOCProps = { type TOCProps = {
@@ -23,6 +11,49 @@ type TOCProps = {
}; };
function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) { function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
function containsArticle(division: Division, articleId: number): boolean {
return division.content.some((c) => {
if (c.type === "division") {
return containsArticle(c, articleId);
}
return c.type === "article" && c.id === articleId;
});
}
function renderDivision(division: Division) {
return (
<details
key={division.title}
className={`toc-division level-${division.level}`}
open={
!!selectedArticleId && containsArticle(division, selectedArticleId)
}
>
<summary>
{division.title}: {division.subtitle}
</summary>
<ul>
{division.content.map((c) => {
if (c.type === "division") {
return renderDivision(c);
} else {
return (
<li
key={c.id}
className={[
"article",
selectedArticleId === c.id ? "selected" : "",
].join(" ")}
onClick={() => onArticleSelected(c.id)}
>
{c.title}: {c.subtitle}
</li>
);
}
})}
</ul>
</details>
);
}
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
return ( return (
@@ -33,29 +64,7 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
> >
{isVisible ? "<" : ">"} {isVisible ? "<" : ">"}
</button> </button>
<ul className="division-list"> {toc.map((division) => renderDivision(division))}
{toc.map((division, index) => (
<li key={index}>
<strong>{division.title}</strong>
<br />
{division.subtitle}
<ul>
{division.articles.map((article) => (
<li
key={article.id}
className={[
"article",
selectedArticleId === article.id ? "selected" : "",
].join(" ")}
onClick={() => onArticleSelected(article.id)}
>
{article.title}: <em>{article.subtitle}</em>
</li>
))}
</ul>
</li>
))}
</ul>
</nav> </nav>
); );
} }

View File

@@ -5,5 +5,19 @@ enum Language {
ITA = "ita", ITA = "ita",
ESP = "esp", ESP = "esp",
} }
type Article = {
type: "article";
id: number;
title: string;
subtitle: string;
};
type Division = {
type: "division";
title: string;
subtitle: string;
level: number;
content: Article[] | Division[];
};
export { Language }; export { Language };
export type { Article, Division };

View File

@@ -2,7 +2,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App.tsx"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import MainView from "./MainView";
import "./index.css"; import "./index.css";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -11,7 +13,14 @@ createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ReactQueryDevtools /> <ReactQueryDevtools />
<App /> <BrowserRouter>
<Routes>
<Route index element={<div>Select a CELEX ID</div>} />
<Route path=":celexId/articles?/:articleId?">
<Route index element={<MainView />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</StrictMode> </StrictMode>
); );

View File

@@ -28,7 +28,7 @@ type CacheKey = tuple[str, Language]
CACHE: dict[CacheKey, str] = {} CACHE: dict[CacheKey, str] = {}
def _get_fmx4_data(celex_id: str, language: Language) -> str: def _get_fmx4_data(celex_id: str, language: Language) -> ET.Element:
""" """
Fetch the FMX4 data from the server. Fetch the FMX4 data from the server.
""" """
@@ -43,9 +43,10 @@ def _get_fmx4_data(celex_id: str, language: Language) -> str:
) )
fmx4_data = client.publication_text(cellar_id, ContentType.ZIP_FMX4) fmx4_data = client.publication_text(cellar_id, ContentType.ZIP_FMX4)
CACHE[(celex_id, language)] = fmx4_data xml = ET.fromstring(fmx4_data.encode("utf-8"))
CACHE[(celex_id, language)] = xml
return fmx4_data return xml
@app.get("/{celex_id}/articles") @app.get("/{celex_id}/articles")
@@ -53,8 +54,7 @@ def article_ids(celex_id: str, language: Language = Language.ENG):
""" """
Fetch the article IDs from the server. Fetch the article IDs from the server.
""" """
fmx4_data = _get_fmx4_data(celex_id, language) xml = _get_fmx4_data(celex_id, language)
xml = ET.fromstring(fmx4_data.encode("utf-8"))
article_xpath = "//ARTICLE/@IDENTIFIER" article_xpath = "//ARTICLE/@IDENTIFIER"
article_ids = xml.xpath(article_xpath) article_ids = xml.xpath(article_xpath)
@@ -65,18 +65,16 @@ def article_ids(celex_id: str, language: Language = Language.ENG):
@app.get("/{celex_id}/toc/{language}") @app.get("/{celex_id}/toc/{language}")
def toc(celex_id: str, language: Language = Language.ENG): def toc(celex_id: str, language: Language = Language.ENG):
""" def _handle_division(division: ET.Element, level: int):
Fetch the table of contents from the server.
"""
fmx4_data = _get_fmx4_data(celex_id, language)
xml = ET.fromstring(fmx4_data.encode("utf-8"))
toc = []
for division in xml.xpath("//DIVISION"):
print(division) print(division)
title = ti_el[0] if (ti_el := division.xpath("TITLE/TI//text()")) else "" title = ti_el[0] if (ti_el := division.xpath("TITLE/TI//text()")) else ""
subtitle = sti_el[0] if (sti_el := division.xpath("TITLE/STI//text()")) else "" subtitle = sti_el[0] if (sti_el := division.xpath("TITLE/STI//text()")) else ""
subdivisions = []
for subdivision in division.xpath("DIVISION") or []:
subdivisions.append(_handle_division(subdivision, level + 1))
articles = [] articles = []
for article in division.xpath("ARTICLE") or []: for article in division.xpath("ARTICLE") or []:
art_id = article.get("IDENTIFIER") art_id = article.get("IDENTIFIER")
@@ -94,14 +92,23 @@ def toc(celex_id: str, language: Language = Language.ENG):
"subtitle": art_subtitle, "subtitle": art_subtitle,
} }
) )
toc.append(
{ return {
"title": title,
"type": "division", "type": "division",
"title": title,
"subtitle": subtitle, "subtitle": subtitle,
"articles": articles, "level": level,
"content": subdivisions + articles,
} }
)
"""
Fetch the table of contents from the server.
"""
xml = _get_fmx4_data(celex_id, language)
toc = []
for division in xml.xpath("//ENACTING.TERMS/DIVISION"):
toc.append(_handle_division(division, 0))
return toc return toc
@@ -111,8 +118,7 @@ def article(celex_id: str, article_id: int, language: Language = Language.ENG):
""" """
Fetch an article from the server. Fetch an article from the server.
""" """
fmx4_data = _get_fmx4_data(celex_id, language) xml = _get_fmx4_data(celex_id, language)
xml = ET.fromstring(fmx4_data.encode("utf-8"))
article_xpath = "//ARTICLE" article_xpath = "//ARTICLE"
articles = xml.xpath(article_xpath) articles = xml.xpath(article_xpath)