Hierarchical TOC + routing
This commit is contained in:
63
frontend/package-lock.json
generated
63
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
13
frontend/src/MainView.tsx
Normal 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;
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Language } from "../lib/types";
|
||||
|
||||
import "./LanguageSwitcher.css";
|
||||
|
||||
function LanguageSwitcher({
|
||||
|
||||
0
frontend/src/components/MainApp.tsx
Normal file
0
frontend/src/components/MainApp.tsx
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user