Compare commits

..

2 Commits

Author SHA1 Message Date
Adrian Rumpold
ad335ad4d3 Frontend: CSS modules, Zustand + URL sync 2025-04-25 08:17:46 +02:00
Adrian Rumpold
e8a9a42ef4 Use zustand for state management 2025-04-24 11:14:05 +02:00
27 changed files with 1527 additions and 321 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,8 @@
"@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" "react-router-dom": "^7.5.1",
"zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
@@ -28,6 +29,7 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.26.1",
"typescript-plugin-css-modules": "^5.1.0",
"vite": "^6.3.1" "vite": "^6.3.1"
} }
} }

View File

@@ -8,7 +8,7 @@
height: 100vh; height: 100vh;
} }
.panel-container { .panelContainer {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 2rem; gap: 2rem;

View File

@@ -1,101 +1,64 @@
import { useState } from "react";
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 ArticleSelector from "./components/ArticleSelector";
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";
type Props = { import ArticleSelector from "./components/ArticleSelector/ArticleSelector";
celexId: string; import Panel from "./components/Panel/Panel";
articleId: number; import TOC from "./components/TOC/TOC";
};
function App({ celexId, articleId }: Props) { import useNavState from "./store/navStore";
const navigate = useNavigate(); import useUIStore from "./store/uiStore";
const [numPanels, setNumPanels] = useState(1);
const [selectedParagraphId, setSelectedParagraphId] = useState<string | null>( import styles from "./App.module.css";
null import CelexSelector from "./components/CelexSelector/CelexSelector";
);
function App() {
const { numPanels, addPanel } = useUIStore();
const { celexId, articleId } = useNavState();
const results = useQueries({ const results = useQueries({
queries: [ queries: [
{ {
queryKey: ["articleIds", celexId], queryKey: ["articleIds", celexId],
queryFn: () => getArticleIds(celexId), queryFn: () => getArticleIds(celexId!),
enabled: !!celexId, enabled: !!celexId,
}, },
{ {
queryKey: ["toc", celexId], queryKey: ["toc", celexId],
queryFn: () => getToc(celexId, Language.ENG), queryFn: () => getToc(celexId!, Language.ENG),
enabled: !!celexId, enabled: !!celexId,
}, },
], ],
}); });
const isPending = results.some((result) => result.isPending); const isPending = results.some((result) => result.isPending);
const error = results.find((result) => result.isError); const error = results.find((result) => result.isError);
if (isPending) { if (isPending) {
return <div className="panel">Loading...</div>; return <div>Loading...</div>;
} }
if (error) { if (error) {
return <div className="panel">Error: {error.error?.message}</div>; return <div>Error: {error.error?.message}</div>;
} }
const examples = [
{ name: "GDPR", id: "32016R0679" },
{ name: "AI Act", id: "32024R1689" },
{ name: "Cyber Resilience Act", id: "32024R2847" },
];
return ( return (
<div className="App"> <div className={styles.App}>
<div className="controls"> <div className={styles.controls}>
<div> <CelexSelector />
<label htmlFor="examples">Select example:</label> <ArticleSelector articleIds={results[0].data!} />
<select <button onClick={addPanel}>Add Panel</button>
id="examples"
value={celexId}
onChange={(e) => {
navigate(`/${e.target.value}`);
}}
>
{examples.map((example) => (
<option key={example.id} value={example.id}>
{example.name}
</option>
))}
</select>
</div>
{/* <CelexSelector defaultId={celexId} onSelected={setCelexId} /> */}
<ArticleSelector
articleIds={results[0].data!}
selectedId={articleId}
onSelected={(id) => navigate(`/${celexId}/articles/${id}`)}
/>
<button onClick={() => setNumPanels((prev) => prev + 1)}>
Add Panel
</button>
</div> </div>
<div className="panel-container"> <div className={styles.panelContainer}>
<TOC <TOC toc={results[1].data!} />
toc={results[1].data!}
selectedArticleId={articleId}
onArticleSelected={(id) => navigate(`/${celexId}/articles/${id}`)}
/>
{Array.from({ length: numPanels }, (_, index) => ( {Array.from({ length: numPanels }, (_, index) => (
<Panel <Panel
key={index} key={index}
celexId={celexId} celexId={celexId!}
language={ language={
Object.values(Language)[index % Object.values(Language).length] Object.values(Language)[index % Object.values(Language).length]
} }
articleId={articleId} articleId={articleId!}
selectedParagraphId={selectedParagraphId || undefined}
onParagraphSelected={setSelectedParagraphId}
/> />
))} ))}
</div> </div>

View File

@@ -1,13 +0,0 @@
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,37 +0,0 @@
type ArticleSelectorProps = {
articleIds: number[];
selectedId: number;
onSelected(articleId: number): void;
};
function ArticleSelector({
articleIds,
selectedId,
onSelected,
}: ArticleSelectorProps) {
return (
<>
{selectedId > 1 && (
<button onClick={() => onSelected(selectedId - 1)}>prev</button>
)}
<select
value={selectedId}
onChange={(e) => {
const id = parseInt(e.currentTarget.value);
onSelected(id);
}}
>
{articleIds.map((id) => (
<option key={id} value={id}>
Article {id}
</option>
))}
</select>
{selectedId < articleIds[articleIds.length - 1] && (
<button onClick={() => onSelected(selectedId + 1)}>next</button>
)}
</>
);
}
export default ArticleSelector;

View File

@@ -0,0 +1,34 @@
import useNavState from "../../store/navStore";
type ArticleSelectorProps = {
articleIds: number[];
};
function ArticleSelector({ articleIds }: ArticleSelectorProps) {
const { articleId, setArticleId } = useNavState();
return (
<>
{articleId && articleId > 1 && (
<button onClick={() => setArticleId(articleId - 1)}>prev</button>
)}
<select
value={articleId || undefined}
onChange={(e) => {
const id = parseInt(e.currentTarget.value);
setArticleId(id);
}}
>
{articleIds.map((id) => (
<option key={id} value={id}>
Article {id}
</option>
))}
</select>
{articleId && articleId < articleIds[articleIds.length - 1] && (
<button onClick={() => setArticleId(articleId + 1)}>next</button>
)}
</>
);
}
export default ArticleSelector;

View File

@@ -1,20 +0,0 @@
type CelexSelectorProps = {
defaultId?: string;
onSelected(celexId: string): void;
};
function CelexSelector({ defaultId, onSelected }: CelexSelectorProps) {
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSelected(e.currentTarget.celexId.value);
}}
>
<label htmlFor="celexId">CELEX ID:</label>
<input type="text" id="celexId" defaultValue={defaultId} />
</form>
);
}
export default CelexSelector;

View File

@@ -0,0 +1,33 @@
import useNavState from "../../store/navStore";
const examples = [
{ name: "GDPR", id: "32016R0679" },
{ name: "AI Act", id: "32024R1689" },
{ name: "Cyber Resilience Act", id: "32024R2847" },
];
function CelexSelector() {
const { celexId, setCelexId, setArticleId } = useNavState();
return (
<div>
<label htmlFor="examples">Select example:</label>
<select
id="examples"
value={celexId || ""}
onChange={(e) => {
setCelexId(e.target.value);
setArticleId(1);
}}
>
{examples.map((example) => (
<option key={example.id} value={example.id}>
{example.name}
</option>
))}
</select>
</div>
);
}
export default CelexSelector;

View File

@@ -1,28 +0,0 @@
import { Language } from "../lib/types";
import "./LanguageSwitcher.css";
function LanguageSwitcher({
defaultLang,
onChange,
}: {
defaultLang: Language;
onChange: (lang: Language) => void;
}) {
return (
<>
<select
defaultValue={defaultLang}
onChange={(ev) => onChange(ev.currentTarget.value as Language)}
className="language-switcher"
>
{Object.values(Language).map((lang) => (
<option key={lang} value={lang}>
{lang.toUpperCase()}
</option>
))}
</select>
</>
);
}
export default LanguageSwitcher;

View File

@@ -1,4 +1,4 @@
.language-switcher { .languageSwitcher {
margin: 0 10px; margin: 0 10px;
padding: 5px; padding: 5px;
font-size: 14px; font-size: 14px;

View File

@@ -0,0 +1,25 @@
import { Language } from "../../lib/types";
import styles from "./LanguageSwitcher.module.css";
function LanguageSwitcher({
defaultLang,
onChange,
}: {
defaultLang: Language;
onChange: (lang: Language) => void;
}) {
return (
<select
defaultValue={defaultLang}
onChange={(ev) => onChange(ev.currentTarget.value as Language)}
className={styles.languageSwitcher}
>
{Object.values(Language).map((lang) => (
<option key={lang} value={lang}>
{lang.toUpperCase()}
</option>
))}
</select>
);
}
export default LanguageSwitcher;

View File

@@ -1,25 +0,0 @@
.panel {
flex: 1 auto;
padding: 1rem;
border-radius: 8px;
border: 1px solid #ccc;
.highlight {
background-color: rgba(100, 255, 100, 0.2);
}
article {
.list-lower-alpha {
list-style-type: lower-alpha;
}
ol .item-number {
display: none;
}
.paragraph-number {
float: left;
margin-right: 1ch;
}
}
}

View File

@@ -0,0 +1,6 @@
.panel {
flex: 1 auto;
padding: 1rem;
border-radius: 8px;
border: 1px solid #ccc;
}

View File

@@ -1,31 +1,29 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getArticle } from "../lib/api";
import { Language } from "../lib/types"; import { getArticle } from "../../lib/api";
import LanguageSwitcher from "./LanguageSwitcher"; import { Language } from "../../lib/types";
import "./Panel.css"; import useUIStore from "../../store/uiStore";
import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher";
import "../../styles/PanelContent.css";
import styles from "./Panel.module.css";
type PanelProps = { type PanelProps = {
celexId: string; celexId: string;
language?: Language; language?: Language;
articleId: number; articleId: number;
selectedParagraphId?: string;
onParagraphSelected(paragraphId: string): void;
}; };
function Panel({ function Panel({ celexId, language, articleId }: PanelProps) {
celexId, const { selectedParagraphId, setSelectedParagraphId } = useUIStore();
language,
articleId,
onParagraphSelected,
selectedParagraphId,
}: PanelProps) {
const [lang, setLang] = useState(language || Language.ENG); const [lang, setLang] = useState(language || Language.ENG);
const articleRef = useRef<HTMLDivElement>(null); const articleRef = useRef<HTMLDivElement>(null);
const { data, isPending, error } = useQuery({ const { data, isPending, error } = useQuery({
queryKey: ["article", celexId, articleId, lang], queryKey: ["article", celexId, articleId, lang],
queryFn: () => getArticle(celexId, articleId, lang), queryFn: () => getArticle(celexId, articleId, lang),
enabled: !!celexId, enabled: !!celexId && !!articleId,
}); });
useEffect(() => { useEffect(() => {
@@ -39,11 +37,11 @@ function Panel({
p.classList.remove("highlight"); p.classList.remove("highlight");
}); });
if (selectedParagraphId) { if (selectedParagraphId) {
const selectedParagraph = Array.from(paragraphs).find( const el = Array.from(paragraphs).find(
(p) => p.getAttribute("data-paragraph-id") === selectedParagraphId (p) => p.getAttribute("data-paragraph-id") === selectedParagraphId
); );
if (selectedParagraph) { if (el) {
selectedParagraph.classList.add("highlight"); el.classList.add("highlight");
} }
} }
@@ -58,7 +56,7 @@ function Panel({
return; return;
} }
target.classList.add("highlight"); target.classList.add("highlight");
onParagraphSelected(paragraphId); setSelectedParagraphId(paragraphId);
}; };
paragraphs.forEach((element) => { paragraphs.forEach((element) => {
@@ -72,13 +70,13 @@ function Panel({
element.removeEventListener("click", handleClick(element)); element.removeEventListener("click", handleClick(element));
}); });
}; };
}, [articleRef, data, selectedParagraphId, onParagraphSelected]); }, [articleRef, data, selectedParagraphId, setSelectedParagraphId]);
if (isPending) return "Loading..."; if (isPending) return "Loading...";
if (error) return "An error has occurred: " + error.message; if (error) return "An error has occurred: " + error.message;
return ( return (
<div className={"panel"}> <div className={styles.panel}>
<LanguageSwitcher <LanguageSwitcher
defaultLang={lang} defaultLang={lang}
onChange={setLang} onChange={setLang}
@@ -86,7 +84,6 @@ function Panel({
<div <div
ref={articleRef} ref={articleRef}
lang={lang.substring(0, 2)} lang={lang.substring(0, 2)}
className="article-text"
dangerouslySetInnerHTML={{ __html: data || "" }} dangerouslySetInnerHTML={{ __html: data || "" }}
/> />
</div> </div>

View File

@@ -8,10 +8,11 @@
min-width: 0; min-width: 0;
} }
overflow: scroll; overflow-y: scroll;
max-height: 100vh; overflow-x: wrap;
height: 100vh;
.toc-division { .tocDivision {
margin-block: 0.5rem; margin-block: 0.5rem;
cursor: pointer; cursor: pointer;
} }
@@ -30,7 +31,7 @@
} }
} }
.toggle-button { .toggleButton {
position: fixed; position: fixed;
top: 16px; top: 16px;
left: 16px; left: 16px;
@@ -50,6 +51,6 @@
z-index: 1000; z-index: 1000;
} }
.toggle-button:hover { .toggleButton:hover {
background-color: #0056b3; background-color: #0056b3;
} }

View File

@@ -1,16 +1,17 @@
import { useState } from "react"; import { useState } from "react";
import { Division } from "../lib/types"; import { Division } from "../../lib/types";
import "./TOC.css"; import useNavState from "../../store/navStore";
import styles from "./TOC.module.css";
type TOC = Division[]; type TOC = Division[];
type TOCProps = { type TOCProps = {
toc: TOC; toc: TOC;
selectedArticleId?: number;
onArticleSelected: (articleId: number) => void;
}; };
function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) { function TOC({ toc }: TOCProps) {
const { articleId, setArticleId } = useNavState();
function containsArticle(division: Division, articleId: number): boolean { function containsArticle(division: Division, articleId: number): boolean {
return division.content.some((c) => { return division.content.some((c) => {
if (c.type === "division") { if (c.type === "division") {
@@ -19,20 +20,19 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
return c.type === "article" && c.id === articleId; return c.type === "article" && c.id === articleId;
}); });
} }
function renderDivision(division: Division) { function renderDivision(div: Division) {
return ( return (
<details <details
key={division.title} key={div.title}
className={`toc-division level-${division.level}`} className={styles.tocDivision}
open={ data-division-level={div.level}
!!selectedArticleId && containsArticle(division, selectedArticleId) open={!!articleId && containsArticle(div, articleId)}
}
> >
<summary> <summary>
{division.title}: {division.subtitle} {div.title}: {div.subtitle}
</summary> </summary>
<ul> <ul>
{division.content.map((c) => { {div.content.map((c) => {
if (c.type === "division") { if (c.type === "division") {
return renderDivision(c); return renderDivision(c);
} else { } else {
@@ -40,10 +40,10 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
<li <li
key={c.id} key={c.id}
className={[ className={[
"article", styles.article,
selectedArticleId === c.id ? "selected" : "", articleId === c.id ? styles.selected : "",
].join(" ")} ].join(" ")}
onClick={() => onArticleSelected(c.id)} onClick={() => setArticleId(c.id)}
> >
{c.title}: {c.subtitle} {c.title}: {c.subtitle}
</li> </li>
@@ -57,10 +57,10 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
return ( return (
<nav className={`toc ${isVisible ? "" : "hidden"}`}> <nav className={[styles.toc, isVisible ? "" : styles.hidden].join(" ")}>
<button <button
onClick={() => setIsVisible(!isVisible)} onClick={() => setIsVisible(!isVisible)}
className="toggle-button" className={styles.toggleButton}
> >
{isVisible ? "<" : ">"} {isVisible ? "<" : ">"}
</button> </button>

View File

@@ -0,0 +1,38 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import useNavState from "../store/navStore";
export const useUrlSync = () => {
const navigate = useNavigate();
const { celexId: paramCelexId, articleId: paramArticleId } = useParams();
const { articleId, celexId, setArticleId, setCelexId } = useNavState();
// Effect to read from URL when URL changes
useEffect(() => {
if (paramCelexId && paramCelexId !== celexId) {
setCelexId(paramCelexId);
}
if (paramArticleId) {
const parsedArticleId = paramArticleId ? parseInt(paramArticleId) : null;
if (parsedArticleId && parsedArticleId !== articleId) {
setArticleId(parsedArticleId);
}
} else {
// If no articleId in URL, reset Zustand state
setArticleId(null);
}
}, [paramArticleId, paramArticleId]);
// Update the URL when Zustand changes
useEffect(() => {
if (celexId === null) {
return;
}
let targetUrl = `/${celexId}`;
if (articleId !== null) {
targetUrl += `/articles/${articleId}`;
}
navigate(targetUrl, { replace: true });
}, [navigate, celexId, articleId]); // Only sync URL when Zustand changes
};

View File

@@ -1,4 +1,4 @@
import TOC from "../components/TOC"; import TOC from "../components/TOC/TOC";
import { Language } from "./types"; import { Language } from "./types";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;

View File

@@ -3,9 +3,10 @@ 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 { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import MainView from "./MainView"; import MainView from "./pages/MainView";
import "./index.css"; import "./index.css";
import LandingPage from "./pages/LandingPage";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -15,7 +16,7 @@ createRoot(document.getElementById("root")!).render(
<ReactQueryDevtools /> <ReactQueryDevtools />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route index element={<div>Select a CELEX ID</div>} /> <Route index element={<LandingPage />} />
<Route path=":celexId/articles?/:articleId?"> <Route path=":celexId/articles?/:articleId?">
<Route index element={<MainView />} /> <Route index element={<MainView />} />
</Route> </Route>

View File

@@ -0,0 +1,8 @@
import CelexSelector from "../components/CelexSelector/CelexSelector";
import { useUrlSync } from "../hooks/urlSync";
function LandingPage() {
useUrlSync();
return <CelexSelector />;
}
export default LandingPage;

View File

@@ -0,0 +1,16 @@
import App from "../App";
import { useUrlSync } from "../hooks/urlSync";
import useNavState from "../store/navStore";
function MainView() {
useUrlSync();
const celexId = useNavState((state) => state.celexId);
if (!celexId) {
return <div>Error: No CELEX ID provided</div>;
}
return <App />;
}
export default MainView;

View File

@@ -0,0 +1,17 @@
import { create } from "zustand";
interface NavState {
celexId: string | null;
articleId: number | null;
setCelexId: (celexId: string) => void;
setArticleId: (articleId: number | null) => void;
}
const useNavState = create<NavState>((set) => ({
celexId: null,
articleId: null,
setCelexId: (celexId) => set({ celexId }),
setArticleId: (articleId) => set({ articleId }),
}));
export default useNavState;

View File

@@ -0,0 +1,21 @@
import { create } from "zustand";
interface UIState {
numPanels: number;
addPanel: () => void;
removePanel: () => void;
selectedParagraphId: string | null;
setSelectedParagraphId: (selectedParagraphId: string | null) => void;
}
const useUIStore = create<UIState>((set) => ({
numPanels: 1,
selectedParagraphId: null,
addPanel: () => set((state) => ({ numPanels: state.numPanels + 1 })),
removePanel: () =>
set((state) => ({ numPanels: Math.max(state.numPanels - 1, 1) })),
setSelectedParagraphId: (selectedParagraphId: string | null) =>
set({ selectedParagraphId }),
}));
export default useUIStore;

View File

@@ -0,0 +1,20 @@
/* Styles for the embedded content inside the reading panel */
.highlight {
background-color: rgba(100, 255, 100, 0.2);
}
article {
.list-lower-alpha {
list-style-type: lower-alpha;
}
ol .item-number {
display: none;
}
.paragraph-number {
float: left;
margin-right: 1ch;
}
}

View File

@@ -20,7 +20,9 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true,
"plugins": [{ "name": "typescript-plugin-css-modules" }]
}, },
"include": ["src"] "include": ["src"]
} }