feat: searchable DE/EN patent glossary with term suggestions

- New /glossar page with 73 bilingual patent law terms across 5 categories
  (Litigation, Prosecution, UPC, EPA, General)
- Client-side instant search filtering both DE and EN columns
- Category filter pills for quick narrowing
- Suggest-a-term button opens modal form for new term submissions
- Per-term feedback icon for correction suggestions
- Suggestions stored in Supabase (glossar_suggestions table with RLS)
- Go API: GET /api/glossar (terms JSON), POST /api/glossar/suggest
- Full DE/EN i18n support, responsive layout, print-friendly
- Added to sidebar nav, landing page tools section, and build pipeline
This commit is contained in:
m
2026-04-14 20:06:51 +02:00
parent a5fdaff909
commit 0960235bb8
9 changed files with 1019 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderDownloads } from "./src/downloads";
import { renderGlossar } from "./src/glossar";
const DIST = join(import.meta.dir, "dist");
@@ -21,6 +22,7 @@ async function build() {
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/glossar.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -47,6 +49,7 @@ async function build() {
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "glossar.html"), renderGlossar());
console.log("Build complete \u2192 dist/");
}

View File

@@ -0,0 +1,207 @@
import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface Term {
de: string;
en: string;
definition?: string;
category: string;
}
let allTerms: Term[] = [];
let activeCategory = "all";
let searchQuery = "";
const ICON_FEEDBACK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
async function loadTerms() {
const resp = await fetch("/api/glossar");
if (!resp.ok) return;
allTerms = await resp.json();
render();
}
function getFilteredTerms(): Term[] {
let terms = allTerms;
if (activeCategory !== "all") {
terms = terms.filter((t) => t.category === activeCategory);
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
terms = terms.filter(
(t) =>
t.de.toLowerCase().includes(q) ||
t.en.toLowerCase().includes(q) ||
(t.definition && t.definition.toLowerCase().includes(q))
);
}
return terms;
}
function render() {
const tbody = document.getElementById("glossar-body")!;
const empty = document.getElementById("glossar-empty")!;
const count = document.getElementById("glossar-count")!;
const filtered = getFilteredTerms();
count.textContent = `${filtered.length} / ${allTerms.length}`;
if (filtered.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = filtered
.map(
(term) => `<tr data-category="${term.category}">
<td class="glossar-de">${esc(term.de)}</td>
<td class="glossar-en">${esc(term.en)}</td>
<td class="glossar-def">${term.definition ? esc(term.definition) : '<span class="glossar-no-def">\u2014</span>'}</td>
<td class="glossar-actions"><button class="glossar-feedback-btn" type="button" title="${t("glossar.feedback.tooltip")}" data-term-de="${esc(term.de)}" data-term-en="${esc(term.en)}" data-term-cat="${term.category}">${ICON_FEEDBACK}</button></td>
</tr>`
)
.join("");
// Attach feedback button listeners
tbody.querySelectorAll<HTMLButtonElement>(".glossar-feedback-btn").forEach((btn) => {
btn.addEventListener("click", () => {
openSuggestModal("correction", btn.dataset.termDe ?? "", btn.dataset.termEn ?? "", btn.dataset.termCat ?? "General");
});
});
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// --- Search ---
function initSearch() {
const input = document.getElementById("glossar-search") as HTMLInputElement;
input.addEventListener("input", () => {
searchQuery = input.value;
render();
});
}
// --- Category filters ---
function initFilters() {
const container = document.getElementById("glossar-filters")!;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
if (!btn) return;
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
activeCategory = btn.dataset.category ?? "all";
render();
});
}
// --- Suggestion modal ---
function openSuggestModal(type: "new" | "correction", termDE = "", termEN = "", category = "General") {
const modal = document.getElementById("suggest-modal")!;
const titleEl = document.getElementById("modal-title")!;
const typeInput = document.getElementById("suggest-type") as HTMLInputElement;
const existingInput = document.getElementById("suggest-existing") as HTMLInputElement;
const deInput = document.getElementById("suggest-de") as HTMLInputElement;
const enInput = document.getElementById("suggest-en") as HTMLInputElement;
const defInput = document.getElementById("suggest-def") as HTMLTextAreaElement;
const catInput = document.getElementById("suggest-cat") as HTMLSelectElement;
const msg = document.getElementById("suggest-msg")!;
typeInput.value = type;
msg.textContent = "";
msg.className = "form-msg";
if (type === "correction") {
titleEl.textContent = t("glossar.feedback.title");
existingInput.value = termDE;
deInput.value = termDE;
enInput.value = termEN;
defInput.value = "";
catInput.value = category;
} else {
titleEl.textContent = t("glossar.suggest.title");
existingInput.value = "";
deInput.value = "";
enInput.value = "";
defInput.value = "";
catInput.value = "General";
}
modal.style.display = "flex";
deInput.focus();
}
function closeModal() {
document.getElementById("suggest-modal")!.style.display = "none";
}
async function submitSuggestion(e: Event) {
e.preventDefault();
const msg = document.getElementById("suggest-msg")!;
const submitBtn = document.querySelector("#suggest-form .btn-submit") as HTMLButtonElement;
const payload = {
term_de: (document.getElementById("suggest-de") as HTMLInputElement).value.trim(),
term_en: (document.getElementById("suggest-en") as HTMLInputElement).value.trim(),
definition: (document.getElementById("suggest-def") as HTMLTextAreaElement).value.trim(),
category: (document.getElementById("suggest-cat") as HTMLSelectElement).value,
suggestion_type: (document.getElementById("suggest-type") as HTMLInputElement).value,
existing_term_de: (document.getElementById("suggest-existing") as HTMLInputElement).value,
};
if (!payload.term_de || !payload.term_en) {
msg.textContent = t("glossar.suggest.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/glossar/suggest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const data = await resp.json();
msg.textContent = data.error || t("glossar.suggest.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("glossar.suggest.success");
msg.className = "form-msg form-msg-success";
setTimeout(closeModal, 1500);
} catch {
msg.textContent = t("glossar.suggest.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
function initModal() {
document.getElementById("btn-suggest-new")!.addEventListener("click", () => {
openSuggestModal("new");
});
document.getElementById("modal-close")!.addEventListener("click", closeModal);
document.getElementById("modal-cancel")!.addEventListener("click", closeModal);
document.getElementById("suggest-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeModal();
});
document.getElementById("suggest-form")!.addEventListener("submit", submitSuggestion);
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initFilters();
initModal();
onLangChange(render);
loadTerms();
});

View File

@@ -15,6 +15,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.kostenrechner": "Kostenrechner",
"nav.fristenrechner": "Fristenrechner",
"nav.downloads": "Downloads",
"nav.glossar": "Glossar",
"nav.logout": "Abmelden",
// Footer
@@ -35,6 +36,8 @@ const translations: Record<Lang, Record<string, string>> = {
"index.cost.desc": "Sch\u00e4tzung der Verfahrenskosten f\u00fcr DE-Gerichte, UPC und EPA-Verfahren. Gerichts- und Anwaltskosten auf einen Blick.",
"index.deadline.title": "Fristenrechner",
"index.deadline.desc": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.",
"index.glossar.title": "Patentglossar",
"index.glossar.desc": "Zweisprachiges DE/EN-Glossar der wichtigsten Begriffe im Patentrecht. Durchsuchbar nach Kategorien.",
"index.downloads": "Downloads",
"index.style.title": "HL Patents Style",
"index.style.desc": "Word-Vorlage im HL Patents Style. Formatierung, Schriftarten und Makros f\u00fcr standardisierte Schrifts\u00e4tze.",
@@ -153,6 +156,33 @@ const translations: Record<Lang, Record<string, string>> = {
"downloads.style.title": "HL Patents Style",
"downloads.style.desc": "Word-Vorlage im HL Patents Style. Formatierung, Schriftarten und Makros f\u00fcr standardisierte Schrifts\u00e4tze.",
"downloads.btn": "Herunterladen",
// Glossar
"glossar.title": "Patentglossar \u2014 patHoLo",
"glossar.heading": "Patentglossar",
"glossar.subtitle": "Zweisprachiges Glossar der wichtigsten Begriffe im Patentrecht.",
"glossar.search.placeholder": "Suchen...",
"glossar.filter.all": "Alle",
"glossar.filter.litigation": "Litigation",
"glossar.filter.prosecution": "Prosecution",
"glossar.filter.general": "Allgemein",
"glossar.col.de": "Deutsch",
"glossar.col.en": "English",
"glossar.col.definition": "Definition",
"glossar.empty": "Keine Treffer.",
"glossar.suggest": "Begriff vorschlagen",
"glossar.suggest.title": "Neuen Begriff vorschlagen",
"glossar.suggest.de": "Deutscher Begriff",
"glossar.suggest.en": "Englischer Begriff",
"glossar.suggest.definition": "Definition (optional)",
"glossar.suggest.category": "Kategorie",
"glossar.suggest.cancel": "Abbrechen",
"glossar.suggest.submit": "Absenden",
"glossar.suggest.success": "Vorschlag eingereicht. Vielen Dank!",
"glossar.suggest.error.required": "Bitte DE und EN Begriff eingeben.",
"glossar.suggest.error.generic": "Fehler beim Senden. Bitte versuchen Sie es erneut.",
"glossar.feedback.title": "Korrektur vorschlagen",
"glossar.feedback.tooltip": "Korrektur vorschlagen",
},
en: {
@@ -161,6 +191,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.kostenrechner": "Cost Calculator",
"nav.fristenrechner": "Deadline Calculator",
"nav.downloads": "Downloads",
"nav.glossar": "Glossary",
"nav.logout": "Sign Out",
// Footer
@@ -181,6 +212,8 @@ const translations: Record<Lang, Record<string, string>> = {
"index.cost.desc": "Estimate litigation costs for DE courts, UPC, and EPA proceedings. Court and attorney fees at a glance.",
"index.deadline.title": "Deadline Calculator",
"index.deadline.desc": "Calculate procedural deadlines for UPC, German, and EPA proceedings with holiday adjustment.",
"index.glossar.title": "Patent Glossary",
"index.glossar.desc": "Bilingual DE/EN glossary of key patent law terminology. Searchable by category.",
"index.downloads": "Downloads",
"index.style.title": "HL Patents Style",
"index.style.desc": "Word template in HL Patents style. Formatting, fonts, and macros for standardised briefs.",
@@ -299,6 +332,33 @@ const translations: Record<Lang, Record<string, string>> = {
"downloads.style.title": "HL Patents Style",
"downloads.style.desc": "Word template in HL Patents style. Formatting, fonts, and macros for standardised briefs.",
"downloads.btn": "Download",
// Glossar
"glossar.title": "Patent Glossary \u2014 patHoLo",
"glossar.heading": "Patent Glossary",
"glossar.subtitle": "Bilingual glossary of key patent law terminology.",
"glossar.search.placeholder": "Search...",
"glossar.filter.all": "All",
"glossar.filter.litigation": "Litigation",
"glossar.filter.prosecution": "Prosecution",
"glossar.filter.general": "General",
"glossar.col.de": "German",
"glossar.col.en": "English",
"glossar.col.definition": "Definition",
"glossar.empty": "No matches found.",
"glossar.suggest": "Suggest a term",
"glossar.suggest.title": "Suggest a new term",
"glossar.suggest.de": "German term",
"glossar.suggest.en": "English term",
"glossar.suggest.definition": "Definition (optional)",
"glossar.suggest.category": "Category",
"glossar.suggest.cancel": "Cancel",
"glossar.suggest.submit": "Submit",
"glossar.suggest.success": "Suggestion submitted. Thank you!",
"glossar.suggest.error.required": "Please enter both DE and EN terms.",
"glossar.suggest.error.generic": "Error submitting. Please try again.",
"glossar.feedback.title": "Suggest a correction",
"glossar.feedback.tooltip": "Suggest a correction",
},
};

View File

@@ -4,6 +4,7 @@ const ICON_HOME = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
const ICON_LOGOUT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
const ICON_PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v6l-2 4h10l-2-4V4"/><line x1="12" y1="16" x2="12" y2="21"/><line x1="8" y1="4" x2="16" y2="4"/></svg>';
@@ -41,6 +42,7 @@ export function Sidebar({ currentPath }: SidebarProps): string {
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
{navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath)}
{navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath)}
{navItem("/glossar", ICON_BOOK, "nav.glossar", "Glossar", currentPath)}
{navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath)}
</nav>

128
frontend/src/glossar.tsx Normal file
View File

@@ -0,0 +1,128 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderGlossar(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="glossar.title">Patentglossar &mdash; patHoLo</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/glossar" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="glossar-header-row">
<div>
<h1 data-i18n="glossar.heading">Patentglossar</h1>
<p className="tool-subtitle" data-i18n="glossar.subtitle">
Zweisprachiges Glossar der wichtigsten Begriffe im Patentrecht.
</p>
</div>
<button className="btn-suggest" id="btn-suggest-new" type="button">
<span className="btn-suggest-icon">+</span>
<span data-i18n="glossar.suggest">Begriff vorschlagen</span>
</button>
</div>
</div>
<div className="glossar-controls">
<div className="glossar-search-wrap">
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
id="glossar-search"
className="glossar-search"
placeholder="Suchen / Search..."
data-i18n-placeholder="glossar.search.placeholder"
autocomplete="off"
/>
<span className="glossar-count" id="glossar-count" />
</div>
<div className="glossar-filters" id="glossar-filters">
<button className="filter-pill active" data-category="all" type="button" data-i18n="glossar.filter.all">Alle</button>
<button className="filter-pill" data-category="Litigation" type="button" data-i18n="glossar.filter.litigation">Litigation</button>
<button className="filter-pill" data-category="Prosecution" type="button" data-i18n="glossar.filter.prosecution">Prosecution</button>
<button className="filter-pill" data-category="UPC" type="button">UPC</button>
<button className="filter-pill" data-category="EPA" type="button">EPA</button>
<button className="filter-pill" data-category="General" type="button" data-i18n="glossar.filter.general">Allgemein</button>
</div>
</div>
<div className="glossar-table-wrap">
<table className="glossar-table" id="glossar-table">
<thead>
<tr>
<th data-i18n="glossar.col.de">Deutsch</th>
<th data-i18n="glossar.col.en">English</th>
<th data-i18n="glossar.col.definition">Definition</th>
<th className="glossar-col-actions" />
</tr>
</thead>
<tbody id="glossar-body" />
</table>
</div>
<div className="glossar-empty" id="glossar-empty" style="display:none">
<p data-i18n="glossar.empty">Keine Treffer.</p>
</div>
{/* Suggestion modal */}
<div className="modal-overlay" id="suggest-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 id="modal-title" data-i18n="glossar.suggest.title">Begriff vorschlagen</h2>
<button className="modal-close" id="modal-close" type="button">&times;</button>
</div>
<form id="suggest-form">
<input type="hidden" id="suggest-type" value="new" />
<input type="hidden" id="suggest-existing" value="" />
<div className="form-field">
<label htmlFor="suggest-de" data-i18n="glossar.suggest.de">Deutscher Begriff</label>
<input type="text" id="suggest-de" required />
</div>
<div className="form-field">
<label htmlFor="suggest-en" data-i18n="glossar.suggest.en">English term</label>
<input type="text" id="suggest-en" required />
</div>
<div className="form-field">
<label htmlFor="suggest-def" data-i18n="glossar.suggest.definition">Definition (optional)</label>
<textarea id="suggest-def" rows={3} />
</div>
<div className="form-field">
<label htmlFor="suggest-cat" data-i18n="glossar.suggest.category">Kategorie</label>
<select id="suggest-cat" required>
<option value="Litigation">Litigation</option>
<option value="Prosecution">Prosecution</option>
<option value="UPC">UPC</option>
<option value="EPA">EPA</option>
<option value="General" data-i18n="glossar.filter.general">Allgemein</option>
</select>
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="glossar.suggest.cancel">Abbrechen</button>
<button type="submit" className="btn-submit" data-i18n="glossar.suggest.submit">Absenden</button>
</div>
<p className="form-msg" id="suggest-msg" />
</form>
</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/glossar.js"></script>
</body>
</html>
);
}

View File

@@ -8,6 +8,7 @@ const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_GLOSSAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
export function renderIndex(): string {
return "<!DOCTYPE html>" + (
@@ -70,6 +71,12 @@ export function renderIndex(): string {
<h2 data-i18n="index.deadline.title">Fristenrechner</h2>
<p data-i18n="index.deadline.desc">Berechnung von Verfahrensfristen f&uuml;r UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.</p>
</a>
<a href="/glossar" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_GLOSSAR }} />
<h2 data-i18n="index.glossar.title">Patentglossar</h2>
<p data-i18n="index.glossar.desc">Zweisprachiges DE/EN-Glossar der wichtigsten Begriffe im Patentrecht. Durchsuchbar nach Kategorien.</p>
</a>
</div>
</div>
</section>

View File

@@ -1432,6 +1432,362 @@ input[type="range"]::-moz-range-thumb {
}
}
/* --- Glossar --- */
.glossar-header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.btn-suggest {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1.1rem;
font-size: 0.85rem;
font-weight: 600;
color: #fff;
background: var(--color-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
white-space: nowrap;
transition: background 0.15s ease;
flex-shrink: 0;
margin-top: 0.25rem;
}
.btn-suggest:hover {
background: var(--color-accent-light);
}
.btn-suggest-icon {
font-size: 1.1rem;
font-weight: 700;
line-height: 1;
}
.glossar-controls {
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.glossar-search-wrap {
position: relative;
display: flex;
align-items: center;
}
.glossar-search-icon {
position: absolute;
left: 0.75rem;
width: 1.1rem;
height: 1.1rem;
color: var(--color-text-muted);
pointer-events: none;
}
.glossar-search {
width: 100%;
padding: 0.65rem 0.75rem 0.65rem 2.5rem;
font-size: 0.92rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-sans);
outline: none;
transition: border-color 0.15s ease;
}
.glossar-search:focus {
border-color: var(--color-accent);
}
.glossar-count {
position: absolute;
right: 0.75rem;
font-size: 0.78rem;
color: var(--color-text-muted);
}
.glossar-filters {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.filter-pill {
padding: 0.35rem 0.85rem;
font-size: 0.8rem;
font-weight: 500;
border: 1px solid var(--color-border);
border-radius: 99px;
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s ease;
font-family: var(--font-sans);
}
.filter-pill:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.filter-pill.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.glossar-table-wrap {
overflow-x: auto;
}
.glossar-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.glossar-table thead th {
text-align: left;
padding: 0.7rem 0.75rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
border-bottom: 2px solid var(--color-border);
white-space: nowrap;
}
.glossar-table tbody tr {
border-bottom: 1px solid var(--color-border);
transition: background 0.1s ease;
}
.glossar-table tbody tr:hover {
background: rgba(101, 163, 13, 0.04);
}
.glossar-table td {
padding: 0.65rem 0.75rem;
vertical-align: top;
}
.glossar-de {
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.glossar-en {
color: var(--color-text);
}
.glossar-def {
color: var(--color-text-muted);
font-size: 0.84rem;
line-height: 1.5;
max-width: 380px;
}
.glossar-no-def {
color: var(--color-border);
}
.glossar-col-actions {
width: 2.5rem;
}
.glossar-actions {
text-align: center;
}
.glossar-feedback-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
opacity: 0.4;
}
.glossar-table tbody tr:hover .glossar-feedback-btn {
opacity: 1;
}
.glossar-feedback-btn:hover {
background: rgba(101, 163, 13, 0.1);
color: var(--color-accent);
opacity: 1;
}
.glossar-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.95rem;
}
/* --- Modal --- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
}
.modal-card {
background: var(--color-surface);
border-radius: calc(var(--radius) * 1.5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 0;
}
.modal-header h2 {
font-size: 1.15rem;
font-weight: 700;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--color-text-muted);
line-height: 1;
padding: 0.25rem;
}
.modal-close:hover {
color: var(--color-text);
}
#suggest-form {
padding: 1.25rem 1.5rem 1.5rem;
}
.form-field {
margin-bottom: 1rem;
}
.form-field label {
display: block;
font-size: 0.82rem;
font-weight: 600;
margin-bottom: 0.3rem;
color: var(--color-text);
}
.form-field input,
.form-field textarea,
.form-field select {
width: 100%;
padding: 0.55rem 0.75rem;
font-size: 0.88rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
outline: none;
transition: border-color 0.15s ease;
}
.form-field input:focus,
.form-field textarea:focus,
.form-field select:focus {
border-color: var(--color-accent);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.25rem;
}
.btn-cancel {
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 500;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
font-family: var(--font-sans);
transition: border-color 0.15s ease;
}
.btn-cancel:hover {
border-color: var(--color-text-muted);
}
.btn-submit {
padding: 0.5rem 1.25rem;
font-size: 0.85rem;
font-weight: 600;
border: none;
border-radius: var(--radius);
background: var(--color-accent);
color: #fff;
cursor: pointer;
font-family: var(--font-sans);
transition: background 0.15s ease;
}
.btn-submit:hover {
background: var(--color-accent-light);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-msg {
margin-top: 0.75rem;
font-size: 0.82rem;
min-height: 1.25rem;
}
.form-msg-error {
color: #dc2626;
}
.form-msg-success {
color: var(--color-accent);
}
/* --- Responsive: General --- */
/* --- Downloads --- */
@@ -1556,6 +1912,27 @@ input[type="range"]::-moz-range-thumb {
gap: 0.25rem;
}
.glossar-header-row {
flex-direction: column;
}
.glossar-de {
white-space: normal;
}
.glossar-def {
max-width: none;
}
.glossar-table {
font-size: 0.82rem;
}
.glossar-table td,
.glossar-table thead th {
padding: 0.5rem;
}
.download-card {
flex-direction: column;
text-align: center;
@@ -1570,7 +1947,7 @@ input[type="range"]::-moz-range-thumb {
/* --- Print --- */
@media print {
.header, .footer, .sidebar, .sidebar-hamburger, .sidebar-overlay, .tool-input, .print-btn, .reset-btn, #step-1, #step-2, .calculate-btn {
.header, .footer, .sidebar, .sidebar-hamburger, .sidebar-overlay, .tool-input, .print-btn, .reset-btn, #step-1, #step-2, .calculate-btn, .modal-overlay, .glossar-feedback-btn, .btn-suggest {
display: none !important;
}