Compare commits
2 Commits
a15ceaa6e3
...
ad335ad4d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad335ad4d3 | ||
|
|
e8a9a42ef4 |
1311
frontend/package-lock.json
generated
1311
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
34
frontend/src/components/ArticleSelector/ArticleSelector.tsx
Normal file
34
frontend/src/components/ArticleSelector/ArticleSelector.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
33
frontend/src/components/CelexSelector/CelexSelector.tsx
Normal file
33
frontend/src/components/CelexSelector/CelexSelector.tsx
Normal 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;
|
||||||
@@ -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;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.language-switcher {
|
.languageSwitcher {
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
frontend/src/components/Panel/Panel.module.css
Normal file
6
frontend/src/components/Panel/Panel.module.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.panel {
|
||||||
|
flex: 1 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
38
frontend/src/hooks/urlSync.ts
Normal file
38
frontend/src/hooks/urlSync.ts
Normal 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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
8
frontend/src/pages/LandingPage.tsx
Normal file
8
frontend/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import CelexSelector from "../components/CelexSelector/CelexSelector";
|
||||||
|
import { useUrlSync } from "../hooks/urlSync";
|
||||||
|
|
||||||
|
function LandingPage() {
|
||||||
|
useUrlSync();
|
||||||
|
return <CelexSelector />;
|
||||||
|
}
|
||||||
|
export default LandingPage;
|
||||||
16
frontend/src/pages/MainView.tsx
Normal file
16
frontend/src/pages/MainView.tsx
Normal 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;
|
||||||
17
frontend/src/store/navStore.ts
Normal file
17
frontend/src/store/navStore.ts
Normal 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;
|
||||||
21
frontend/src/store/uiStore.ts
Normal file
21
frontend/src/store/uiStore.ts
Normal 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;
|
||||||
20
frontend/src/styles/PanelContent.css
Normal file
20
frontend/src/styles/PanelContent.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user