Frontend: CSS modules, Zustand + URL sync

This commit is contained in:
Adrian Rumpold
2025-04-25 08:17:46 +02:00
parent e8a9a42ef4
commit ad335ad4d3
26 changed files with 1457 additions and 294 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@
"globals": "^16.0.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"typescript-plugin-css-modules": "^5.1.0",
"vite": "^6.3.1"
}
}

View File

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

View File

@@ -1,97 +1,64 @@
import { useQueries } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { getArticleIds, getToc } from "./lib/api";
import { Language } from "./lib/types";
import ArticleSelector from "./components/ArticleSelector";
import Panel from "./components/Panel";
import TOC from "./components/TOC";
import ArticleSelector from "./components/ArticleSelector/ArticleSelector";
import Panel from "./components/Panel/Panel";
import TOC from "./components/TOC/TOC";
import useNavState from "./store/navStore";
import useUIStore from "./store/uiStore";
import "./App.css";
import styles from "./App.module.css";
import CelexSelector from "./components/CelexSelector/CelexSelector";
type Props = {
celexId: string;
articleId: number;
};
function App({ celexId, articleId }: Props) {
const navigate = useNavigate();
function App() {
const { numPanels, addPanel } = useUIStore();
const { celexId, articleId } = useNavState();
const results = useQueries({
queries: [
{
queryKey: ["articleIds", celexId],
queryFn: () => getArticleIds(celexId),
queryFn: () => getArticleIds(celexId!),
enabled: !!celexId,
},
{
queryKey: ["toc", celexId],
queryFn: () => getToc(celexId, Language.ENG),
queryFn: () => getToc(celexId!, Language.ENG),
enabled: !!celexId,
},
],
});
const isPending = results.some((result) => result.isPending);
const error = results.find((result) => result.isError);
if (isPending) {
return <div className="panel">Loading...</div>;
return <div>Loading...</div>;
}
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 (
<div className="App">
<div className="controls">
<div>
<label htmlFor="examples">Select example:</label>
<select
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}`)}
/>
<div className={styles.App}>
<div className={styles.controls}>
<CelexSelector />
<ArticleSelector articleIds={results[0].data!} />
<button onClick={addPanel}>Add Panel</button>
</div>
<div className="panel-container">
<TOC
toc={results[1].data!}
selectedArticleId={articleId}
onArticleSelected={(id) => navigate(`/${celexId}/articles/${id}`)}
/>
<div className={styles.panelContainer}>
<TOC toc={results[1].data!} />
{Array.from({ length: numPanels }, (_, index) => (
<Panel
key={index}
celexId={celexId}
celexId={celexId!}
language={
Object.values(Language)[index % Object.values(Language).length]
}
articleId={articleId}
articleId={articleId!}
/>
))}
</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;
padding: 5px;
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,10 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { getArticle } from "../lib/api";
import { Language } from "../lib/types";
import useUIStore from "../store/uiStore";
import LanguageSwitcher from "./LanguageSwitcher";
import "./Panel.css";
import { getArticle } from "../../lib/api";
import { Language } from "../../lib/types";
import useUIStore from "../../store/uiStore";
import LanguageSwitcher from "../LanguageSwitcher/LanguageSwitcher";
import "../../styles/PanelContent.css";
import styles from "./Panel.module.css";
type PanelProps = {
celexId: string;
@@ -20,7 +23,7 @@ function Panel({ celexId, language, articleId }: PanelProps) {
const { data, isPending, error } = useQuery({
queryKey: ["article", celexId, articleId, lang],
queryFn: () => getArticle(celexId, articleId, lang),
enabled: !!celexId,
enabled: !!celexId && !!articleId,
});
useEffect(() => {
@@ -73,7 +76,7 @@ function Panel({ celexId, language, articleId }: PanelProps) {
if (error) return "An error has occurred: " + error.message;
return (
<div className={"panel"}>
<div className={styles.panel}>
<LanguageSwitcher
defaultLang={lang}
onChange={setLang}
@@ -81,7 +84,6 @@ function Panel({ celexId, language, articleId }: PanelProps) {
<div
ref={articleRef}
lang={lang.substring(0, 2)}
className="article-text"
dangerouslySetInnerHTML={{ __html: data || "" }}
/>
</div>

View File

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

View File

@@ -1,16 +1,17 @@
import { useState } from "react";
import { Division } from "../lib/types";
import "./TOC.css";
import { Division } from "../../lib/types";
import useNavState from "../../store/navStore";
import styles from "./TOC.module.css";
type TOC = Division[];
type TOCProps = {
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 {
return division.content.some((c) => {
if (c.type === "division") {
@@ -19,20 +20,19 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
return c.type === "article" && c.id === articleId;
});
}
function renderDivision(division: Division) {
function renderDivision(div: Division) {
return (
<details
key={division.title}
className={`toc-division level-${division.level}`}
open={
!!selectedArticleId && containsArticle(division, selectedArticleId)
}
key={div.title}
className={styles.tocDivision}
data-division-level={div.level}
open={!!articleId && containsArticle(div, articleId)}
>
<summary>
{division.title}: {division.subtitle}
{div.title}: {div.subtitle}
</summary>
<ul>
{division.content.map((c) => {
{div.content.map((c) => {
if (c.type === "division") {
return renderDivision(c);
} else {
@@ -40,10 +40,10 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
<li
key={c.id}
className={[
"article",
selectedArticleId === c.id ? "selected" : "",
styles.article,
articleId === c.id ? styles.selected : "",
].join(" ")}
onClick={() => onArticleSelected(c.id)}
onClick={() => setArticleId(c.id)}
>
{c.title}: {c.subtitle}
</li>
@@ -57,10 +57,10 @@ function TOC({ toc, selectedArticleId, onArticleSelected }: TOCProps) {
const [isVisible, setIsVisible] = useState(true);
return (
<nav className={`toc ${isVisible ? "" : "hidden"}`}>
<nav className={[styles.toc, isVisible ? "" : styles.hidden].join(" ")}>
<button
onClick={() => setIsVisible(!isVisible)}
className="toggle-button"
className={styles.toggleButton}
>
{isVisible ? "<" : ">"}
</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";
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 { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import MainView from "./MainView";
import MainView from "./pages/MainView";
import "./index.css";
import LandingPage from "./pages/LandingPage";
const queryClient = new QueryClient();
@@ -15,7 +16,7 @@ createRoot(document.getElementById("root")!).render(
<ReactQueryDevtools />
<BrowserRouter>
<Routes>
<Route index element={<div>Select a CELEX ID</div>} />
<Route index element={<LandingPage />} />
<Route path=":celexId/articles?/:articleId?">
<Route index element={<MainView />} />
</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,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,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"plugins": [{ "name": "typescript-plugin-css-modules" }]
},
"include": ["src"]
}