Compare commits
2 Commits
ad335ad4d3
...
abb415c380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb415c380 | ||
|
|
f21158c6c0 |
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.74.4",
|
||||||
"@tanstack/react-query-devtools": "^5.74.6",
|
"@tanstack/react-query-devtools": "^5.74.6",
|
||||||
|
"dompurify": "^3.2.5",
|
||||||
"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",
|
||||||
@@ -1881,6 +1882,13 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.31.0",
|
"version": "8.31.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz",
|
||||||
@@ -2414,6 +2422,15 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
|
||||||
|
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.5.0",
|
"version": "16.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.74.4",
|
"@tanstack/react-query": "^5.74.4",
|
||||||
"@tanstack/react-query-devtools": "^5.74.6",
|
"@tanstack/react-query-devtools": "^5.74.6",
|
||||||
|
"dompurify": "^3.2.5",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { useQueries } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { getArticleIds, getToc } from "./lib/api";
|
|
||||||
import { Language } from "./lib/types";
|
import { Language } from "./lib/types";
|
||||||
|
|
||||||
import ArticleSelector from "./components/ArticleSelector/ArticleSelector";
|
import ArticleSelector from "./components/ArticleSelector/ArticleSelector";
|
||||||
@@ -12,45 +9,29 @@ import useUIStore from "./store/uiStore";
|
|||||||
|
|
||||||
import styles from "./App.module.css";
|
import styles from "./App.module.css";
|
||||||
import CelexSelector from "./components/CelexSelector/CelexSelector";
|
import CelexSelector from "./components/CelexSelector/CelexSelector";
|
||||||
|
import { useTOC } from "./hooks/toc";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { numPanels, addPanel } = useUIStore();
|
const { numPanels, addPanel } = useUIStore();
|
||||||
const { celexId, articleId } = useNavState();
|
const { celexId, articleId } = useNavState();
|
||||||
|
const { data: toc, isPending, error } = useTOC();
|
||||||
const results = useQueries({
|
|
||||||
queries: [
|
|
||||||
{
|
|
||||||
queryKey: ["articleIds", celexId],
|
|
||||||
queryFn: () => getArticleIds(celexId!),
|
|
||||||
enabled: !!celexId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
queryKey: ["toc", celexId],
|
|
||||||
queryFn: () => getToc(celexId!, Language.ENG),
|
|
||||||
enabled: !!celexId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const isPending = results.some((result) => result.isPending);
|
|
||||||
const error = results.find((result) => result.isError);
|
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return <div>Loading...</div>;
|
return <div>Loading...</div>;
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div>Error: {error.error?.message}</div>;
|
return <div>Error: {error.message}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.App}>
|
<div className={styles.App}>
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<CelexSelector />
|
<CelexSelector />
|
||||||
<ArticleSelector articleIds={results[0].data!} />
|
<ArticleSelector toc={toc} />
|
||||||
<button onClick={addPanel}>Add Panel</button>
|
<button onClick={addPanel}>Add Panel</button>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.panelContainer}>
|
<div className={styles.panelContainer}>
|
||||||
<TOC toc={results[1].data!} />
|
<TOC toc={toc} />
|
||||||
{Array.from({ length: numPanels }, (_, index) => (
|
{Array.from({ length: numPanels }, (_, index) => (
|
||||||
<Panel
|
<Panel
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.articleSelector {
|
||||||
|
width: 40ch;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
@@ -1,32 +1,54 @@
|
|||||||
|
import { JSX } from "react";
|
||||||
|
import type { Division } from "../../lib/types";
|
||||||
import useNavState from "../../store/navStore";
|
import useNavState from "../../store/navStore";
|
||||||
|
import styles from "./ArticleSelector.module.css";
|
||||||
|
|
||||||
type ArticleSelectorProps = {
|
type ArticleSelectorProps = {
|
||||||
articleIds: number[];
|
toc: Division[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function ArticleSelector({ articleIds }: ArticleSelectorProps) {
|
function ArticleSelector({ toc }: ArticleSelectorProps) {
|
||||||
const { articleId, setArticleId } = useNavState();
|
const { articleId, setArticleId } = useNavState();
|
||||||
|
|
||||||
|
function renderDivision(div: Division): JSX.Element {
|
||||||
|
const contents = div.content.map((c) => {
|
||||||
|
if (c.type === "division") {
|
||||||
|
return renderDivision(c);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}: {c.subtitle}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (div.level === 0) {
|
||||||
|
const title = `${div.title}: ${div.subtitle}`;
|
||||||
|
return (
|
||||||
|
// For top-level divisions, we can use optgroup
|
||||||
|
<optgroup key={title} label={title}>
|
||||||
|
{contents}
|
||||||
|
</optgroup>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// HTML does not support nested optgroups, so we need to flatten the structure
|
||||||
|
return <>{contents}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{articleId && articleId > 1 && (
|
|
||||||
<button onClick={() => setArticleId(articleId - 1)}>prev</button>
|
|
||||||
)}
|
|
||||||
<select
|
<select
|
||||||
value={articleId || undefined}
|
value={articleId || undefined}
|
||||||
|
className={styles.articleSelector}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const id = parseInt(e.currentTarget.value);
|
const id = parseInt(e.target.value);
|
||||||
setArticleId(id);
|
setArticleId(id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{articleIds.map((id) => (
|
{toc.map((div) => renderDivision(div))}
|
||||||
<option key={id} value={id}>
|
|
||||||
Article {id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
{articleId && articleId < articleIds[articleIds.length - 1] && (
|
|
||||||
<button onClick={() => setArticleId(articleId + 1)}>next</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { getArticle } from "../../lib/api";
|
import { getArticle } from "../../lib/api";
|
||||||
@@ -84,7 +85,7 @@ function Panel({ celexId, language, articleId }: PanelProps) {
|
|||||||
<div
|
<div
|
||||||
ref={articleRef}
|
ref={articleRef}
|
||||||
lang={lang.substring(0, 2)}
|
lang={lang.substring(0, 2)}
|
||||||
dangerouslySetInnerHTML={{ __html: data || "" }}
|
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(data) || "" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { Division } from "../../lib/types";
|
|||||||
import useNavState from "../../store/navStore";
|
import useNavState from "../../store/navStore";
|
||||||
import styles from "./TOC.module.css";
|
import styles from "./TOC.module.css";
|
||||||
|
|
||||||
type TOC = Division[];
|
|
||||||
|
|
||||||
type TOCProps = {
|
type TOCProps = {
|
||||||
toc: TOC;
|
toc: Division[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function TOC({ toc }: TOCProps) {
|
function TOC({ toc }: TOCProps) {
|
||||||
|
|||||||
14
frontend/src/hooks/toc.ts
Normal file
14
frontend/src/hooks/toc.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getToc } from "../lib/api";
|
||||||
|
import { Language } from "../lib/types";
|
||||||
|
import useNavState from "../store/navStore";
|
||||||
|
|
||||||
|
export const useTOC = () => {
|
||||||
|
const celexId = useNavState((state) => state.celexId);
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["toc", celexId],
|
||||||
|
queryFn: () => getToc(celexId!, Language.ENG),
|
||||||
|
enabled: !!celexId,
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import TOC from "../components/TOC/TOC";
|
import { Division, 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;
|
||||||
|
|
||||||
@@ -23,10 +22,13 @@ async function getArticleIds(celexId: string): Promise<number[]> {
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getToc(celexId: string, language: Language): Promise<TOC> {
|
async function getToc(
|
||||||
|
celexId: string,
|
||||||
|
language: Language
|
||||||
|
): Promise<Division[]> {
|
||||||
console.debug(`Fetching TOC for CELEX ID ${celexId}`);
|
console.debug(`Fetching TOC for CELEX ID ${celexId}`);
|
||||||
const response = await fetch(`${API_URL}/${celexId}/toc/${language}`);
|
const response = await fetch(`${API_URL}/${celexId}/toc/${language}`);
|
||||||
return await response.json();
|
return (await response.json()) as Division[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getArticle, getArticleIds, getToc };
|
export { getArticle, getArticleIds, getToc };
|
||||||
|
|||||||
@@ -68,9 +68,16 @@ def article_ids(celex_id: str, language: Language = Language.ENG):
|
|||||||
|
|
||||||
@api_router.get("/{celex_id}/toc/{language}")
|
@api_router.get("/{celex_id}/toc/{language}")
|
||||||
def toc(celex_id: str, language: Language = Language.ENG):
|
def toc(celex_id: str, language: Language = Language.ENG):
|
||||||
|
def _extract_text(root: ET.Element, tag: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract text from the given tag in the XML element.
|
||||||
|
"""
|
||||||
|
text = root.xpath(f"{tag}//text()")
|
||||||
|
return "".join(text) if text else ""
|
||||||
|
|
||||||
def _handle_division(division: ET.Element, level: int):
|
def _handle_division(division: ET.Element, level: int):
|
||||||
title = ti_el[0] if (ti_el := division.xpath("TITLE/TI//text()")) else ""
|
title = _extract_text(division, "TITLE/TI")
|
||||||
subtitle = sti_el[0] if (sti_el := division.xpath("TITLE/STI//text()")) else ""
|
subtitle = _extract_text(division, "TITLE/STI")
|
||||||
|
|
||||||
subdivisions = []
|
subdivisions = []
|
||||||
for subdivision in division.xpath("DIVISION") or []:
|
for subdivision in division.xpath("DIVISION") or []:
|
||||||
@@ -81,10 +88,9 @@ def toc(celex_id: str, language: Language = Language.ENG):
|
|||||||
art_id = article.get("IDENTIFIER")
|
art_id = article.get("IDENTIFIER")
|
||||||
if not art_id:
|
if not art_id:
|
||||||
continue
|
continue
|
||||||
art_title = ti_el[0] if (ti_el := article.xpath("TI.ART//text()")) else ""
|
|
||||||
art_subtitle = (
|
art_title = _extract_text(article, "TI.ART")
|
||||||
sti_el[0] if (sti_el := article.xpath("STI.ART//text()")) else ""
|
art_subtitle = _extract_text(article, "STI.ART")
|
||||||
)
|
|
||||||
articles.append(
|
articles.append(
|
||||||
{
|
{
|
||||||
"id": int(art_id.lstrip("0")),
|
"id": int(art_id.lstrip("0")),
|
||||||
|
|||||||
Reference in New Issue
Block a user