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-devtools": "^5.74.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.1"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
@@ -1934,6 +1935,15 @@
"dev": true,
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2905,6 +2915,45 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3006,6 +3055,12 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -3136,6 +3191,12 @@
"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": {
"version": "0.4.0",
"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-devtools": "^5.74.6",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"react-router-dom": "^7.5.1"
},
"devDependencies": {
"@eslint/js": "^9.22.0",

View File

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

View File

View File

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

View File

@@ -1,19 +1,7 @@
import { useState } from "react";
import { Division } from "../lib/types";
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 TOCProps = {
@@ -23,6 +11,49 @@ type 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);
return (
@@ -33,29 +64,7 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
>
{isVisible ? "<" : ">"}
</button>
<ul className="division-list">
{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>
{toc.map((division) => renderDivision(division))}
</nav>
);
}

View File

@@ -5,5 +5,19 @@ enum Language {
ITA = "ita",
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 type { Article, Division };

View File

@@ -2,7 +2,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { StrictMode } from "react";
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";
const queryClient = new QueryClient();
@@ -11,7 +13,14 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<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>
</StrictMode>
);