diff --git a/docs/migrations/003_gerichte_feedback.sql b/docs/migrations/003_gerichte_feedback.sql new file mode 100644 index 0000000..77523e2 --- /dev/null +++ b/docs/migrations/003_gerichte_feedback.sql @@ -0,0 +1,25 @@ +-- Migration: Gerichtsverzeichnis feedback table for patholo +-- Target: ydb.youpc.org (patholo's Supabase instance) +-- Apply via Supabase SQL editor or psql + +CREATE TABLE IF NOT EXISTS gerichte_feedback ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + court_id text DEFAULT '', + feedback_type text NOT NULL, + message text NOT NULL, + submitted_by text DEFAULT '', + created_at timestamptz NOT NULL DEFAULT now() +); + +-- RLS: enabled, server-side patholo backend authenticates before forwarding. +ALTER TABLE gerichte_feedback ENABLE ROW LEVEL SECURITY; + +-- Allow authenticated inserts via the anon/authed key forwarded by the Go server. +CREATE POLICY "gerichte_feedback_insert" ON gerichte_feedback + FOR INSERT WITH CHECK (true); + +-- Allow submitter to read back own rows (optional; not currently used by UI). +CREATE POLICY "gerichte_feedback_select_own" ON gerichte_feedback + FOR SELECT USING ( + submitted_by = coalesce((auth.jwt() ->> 'email'), '') + ); diff --git a/frontend/build.ts b/frontend/build.ts index 0d95d6b..5b92fe8 100644 --- a/frontend/build.ts +++ b/frontend/build.ts @@ -10,6 +10,7 @@ import { renderGlossar } from "./src/glossar"; import { renderGebuehrentabellen } from "./src/gebuehrentabellen"; import { renderChecklisten } from "./src/checklisten"; import { renderChecklistenDetail } from "./src/checklisten-detail"; +import { renderGerichte } from "./src/gerichte"; const DIST = join(import.meta.dir, "dist"); @@ -31,6 +32,7 @@ async function build() { join(import.meta.dir, "src/client/gebuehrentabellen.ts"), join(import.meta.dir, "src/client/checklisten.ts"), join(import.meta.dir, "src/client/checklisten-detail.ts"), + join(import.meta.dir, "src/client/gerichte.ts"), ], outdir: join(DIST, "assets"), naming: "[name].js", @@ -62,6 +64,7 @@ async function build() { await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen()); await Bun.write(join(DIST, "checklisten.html"), renderChecklisten()); await Bun.write(join(DIST, "checklisten-detail.html"), renderChecklistenDetail()); + await Bun.write(join(DIST, "gerichte.html"), renderGerichte()); console.log("Build complete \u2192 dist/"); } diff --git a/frontend/src/client/gerichte.ts b/frontend/src/client/gerichte.ts new file mode 100644 index 0000000..d2ee7a1 --- /dev/null +++ b/frontend/src/client/gerichte.ts @@ -0,0 +1,381 @@ +import { getLang, initI18n, onLangChange, t } from "./i18n"; +import { initSidebar } from "./sidebar"; + +interface Court { + id: string; + nameDE: string; + nameEN: string; + type: string; + group: string; + country: string; + city: string; + address?: string; + phone?: string; + fax?: string; + email?: string; + website?: string; + languages?: string[]; + filing?: string; + notesDE?: string; + notesEN?: string; + hlContact?: string; +} + +interface CourtType { + key: string; + labelDE: string; + labelEN: string; + group: string; +} + +interface ApiResponse { + courts: Court[]; + types: CourtType[]; +} + +const COUNTRY_NAMES_DE: Record = { + DE: "Deutschland", + FR: "Frankreich", + NL: "Niederlande", + IT: "Italien", + LU: "Luxemburg", + BE: "Belgien", + AT: "\u00d6sterreich", + PT: "Portugal", + FI: "Finnland", + SE: "Schweden", + DK: "D\u00e4nemark", + EE: "Estland", + LV: "Lettland", + LT: "Litauen", + SI: "Slowenien", + GB: "Vereinigtes K\u00f6nigreich", +}; + +const COUNTRY_NAMES_EN: Record = { + DE: "Germany", + FR: "France", + NL: "Netherlands", + IT: "Italy", + LU: "Luxembourg", + BE: "Belgium", + AT: "Austria", + PT: "Portugal", + FI: "Finland", + SE: "Sweden", + DK: "Denmark", + EE: "Estonia", + LV: "Latvia", + LT: "Lithuania", + SI: "Slovenia", + GB: "United Kingdom", +}; + +let allCourts: Court[] = []; +let typeMap: Record = {}; +let expanded: Set = new Set(); +let activeGroup = "all"; +let activeCountry = "all"; +let searchQuery = ""; + +const ICON_MAIL = ''; +const ICON_PHONE = ''; +const ICON_LINK = ''; +const ICON_PIN = ''; + +function esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; +} + +function name(court: Court): string { + return getLang() === "en" ? court.nameEN : court.nameDE; +} + +function notes(court: Court): string { + return getLang() === "en" ? (court.notesEN ?? "") : (court.notesDE ?? ""); +} + +function typeLabel(key: string): string { + const ct = typeMap[key]; + if (!ct) return key; + return getLang() === "en" ? ct.labelEN : ct.labelDE; +} + +function countryName(code: string): string { + const map = getLang() === "en" ? COUNTRY_NAMES_EN : COUNTRY_NAMES_DE; + return map[code] ?? code; +} + +async function loadCourts() { + const resp = await fetch("/api/gerichte"); + if (!resp.ok) return; + const data: ApiResponse = await resp.json(); + allCourts = data.courts; + typeMap = {}; + for (const ct of data.types) typeMap[ct.key] = ct; + buildCountryFilters(); + render(); +} + +function buildCountryFilters() { + const container = document.getElementById("gerichte-country-filters")!; + const countries = Array.from(new Set(allCourts.map((c) => c.country))).sort(); + const allBtn = '"; + const pills = countries + .map((c) => ``) + .join(""); + container.innerHTML = allBtn + pills; +} + +function matches(court: Court): boolean { + if (activeGroup !== "all" && court.group !== activeGroup) return false; + if (activeCountry !== "all" && court.country !== activeCountry) return false; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + const hay = [ + court.nameDE, + court.nameEN, + court.city, + court.country, + countryName(court.country), + typeLabel(court.type), + court.address ?? "", + ] + .join(" ") + .toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; +} + +function render() { + const list = document.getElementById("gerichte-list")!; + const empty = document.getElementById("gerichte-empty")!; + const count = document.getElementById("gerichte-count")!; + const filtered = allCourts.filter(matches); + + count.textContent = `${filtered.length} / ${allCourts.length}`; + + if (filtered.length === 0) { + list.innerHTML = ""; + empty.style.display = "block"; + return; + } + empty.style.display = "none"; + + list.innerHTML = filtered.map(renderCard).join(""); + + list.querySelectorAll(".gericht-summary").forEach((el) => { + el.addEventListener("click", () => { + const id = el.dataset.id!; + if (expanded.has(id)) expanded.delete(id); + else expanded.add(id); + render(); + }); + }); + + list.querySelectorAll(".gericht-feedback-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + openFeedbackModal(btn.dataset.id!, btn.dataset.label!); + }); + }); +} + +function renderCard(court: Court): string { + const isOpen = expanded.has(court.id); + const hasDetails = + !!(court.address || court.phone || court.fax || court.email || court.website || court.filing || notes(court) || court.hlContact); + + return ` +
+
+
+
${esc(name(court))}
+
+ ${esc(typeLabel(court.type))} + ${ICON_PIN}${esc(court.city)}, ${esc(countryName(court.country))} + ${court.languages && court.languages.length ? `${court.languages.map((l) => esc(l)).join(" / ")}` : ""} +
+
+
+ + +
+
+ ${hasDetails ? renderDetails(court) : ""} +
`; +} + +function renderDetails(court: Court): string { + const parts: string[] = []; + + if (court.address) { + parts.push(` +
+
${t("gerichte.field.address")}
+
${esc(court.address)}
+
`); + } + + const contactBits: string[] = []; + if (court.phone) contactBits.push(`${ICON_PHONE}${esc(court.phone)}`); + if (court.fax) contactBits.push(`${t("gerichte.field.fax")}: ${esc(court.fax)}`); + if (court.email) contactBits.push(`${ICON_MAIL}${esc(court.email)}`); + if (court.website) contactBits.push(`${ICON_LINK}${esc(court.website.replace(/^https?:\/\//, ""))}`); + if (contactBits.length) { + parts.push(` +
+
${t("gerichte.field.contact")}
+
${contactBits.join("")}
+
`); + } + + if (court.filing) { + parts.push(` +
+
${t("gerichte.field.filing")}
+
${esc(court.filing)}
+
`); + } + + const note = notes(court); + if (note) { + parts.push(` +
+
${t("gerichte.field.notes")}
+
${esc(note)}
+
`); + } + + if (court.hlContact) { + parts.push(` +
+
${t("gerichte.field.hlContact")}
+
${esc(court.hlContact)}
+
`); + } + + return `
${parts.join("")}
`; +} + +// --- Filters --- +function initFilters() { + const groupContainer = document.getElementById("gerichte-group-filters")!; + groupContainer.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".filter-pill"); + if (!btn) return; + groupContainer.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + activeGroup = btn.dataset.group ?? "all"; + render(); + }); + + const countryContainer = document.getElementById("gerichte-country-filters")!; + countryContainer.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".filter-pill"); + if (!btn) return; + countryContainer.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active")); + btn.classList.add("active"); + activeCountry = btn.dataset.country ?? "all"; + render(); + }); +} + +// --- Search --- +function initSearch() { + const input = document.getElementById("gerichte-search") as HTMLInputElement; + input.addEventListener("input", () => { + searchQuery = input.value; + render(); + }); +} + +// --- Feedback modal --- +function openFeedbackModal(courtId: string, label: string) { + const modal = document.getElementById("feedback-modal")!; + (document.getElementById("feedback-court-id") as HTMLInputElement).value = courtId; + (document.getElementById("feedback-court-label") as HTMLInputElement).value = label; + (document.getElementById("feedback-message") as HTMLTextAreaElement).value = ""; + (document.getElementById("feedback-type") as HTMLSelectElement).value = "address"; + const msg = document.getElementById("feedback-msg")!; + msg.textContent = ""; + msg.className = "form-msg"; + modal.style.display = "flex"; + (document.getElementById("feedback-message") as HTMLTextAreaElement).focus(); +} + +function openGenericFeedbackModal() { + openFeedbackModal("", ""); + (document.getElementById("feedback-type") as HTMLSelectElement).value = "missing"; +} + +function closeFeedbackModal() { + document.getElementById("feedback-modal")!.style.display = "none"; +} + +async function submitFeedback(e: Event) { + e.preventDefault(); + const msg = document.getElementById("feedback-msg")!; + const submitBtn = document.querySelector("#feedback-form .btn-submit") as HTMLButtonElement; + + const payload = { + court_id: (document.getElementById("feedback-court-id") as HTMLInputElement).value, + feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value, + message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(), + }; + + if (!payload.message) { + msg.textContent = t("gerichte.feedback.error.required"); + msg.className = "form-msg form-msg-error"; + return; + } + + submitBtn.disabled = true; + try { + const resp = await fetch("/api/gerichte/feedback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + msg.textContent = data.error || t("gerichte.feedback.error.generic"); + msg.className = "form-msg form-msg-error"; + return; + } + msg.textContent = t("gerichte.feedback.success"); + msg.className = "form-msg form-msg-success"; + setTimeout(closeFeedbackModal, 1500); + } catch { + msg.textContent = t("gerichte.feedback.error.generic"); + msg.className = "form-msg form-msg-error"; + } finally { + submitBtn.disabled = false; + } +} + +function initModal() { + document.getElementById("btn-feedback")!.addEventListener("click", openGenericFeedbackModal); + document.getElementById("feedback-close")!.addEventListener("click", closeFeedbackModal); + document.getElementById("feedback-cancel")!.addEventListener("click", closeFeedbackModal); + document.getElementById("feedback-modal")!.addEventListener("click", (e) => { + if (e.target === e.currentTarget) closeFeedbackModal(); + }); + document.getElementById("feedback-form")!.addEventListener("submit", submitFeedback); +} + +document.addEventListener("DOMContentLoaded", () => { + initI18n(); + initSidebar(); + initSearch(); + initFilters(); + initModal(); + onLangChange(() => { + buildCountryFilters(); + render(); + }); + loadCourts(); +}); diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 0ab4e28..556b3a6 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -19,6 +19,7 @@ const translations: Record> = { "nav.glossar": "Glossar", "nav.gebuehrentabellen": "Geb\u00fchrentabellen", "nav.checklisten": "Checklisten", + "nav.gerichte": "Gerichte", "nav.logout": "Abmelden", // Footer @@ -305,6 +306,43 @@ const translations: Record> = { "checklisten.feedback.success": "Danke f\u00fcr Ihr Feedback!", "checklisten.feedback.error.required": "Bitte geben Sie eine Nachricht ein.", "checklisten.feedback.error.generic": "Fehler beim Senden. Bitte versuchen Sie es erneut.", + + // Gerichte + "gerichte.title": "Gerichtsverzeichnis \u2014 patHoLo", + "gerichte.heading": "Gerichtsverzeichnis", + "gerichte.subtitle": "Kontaktdaten, Adressen und Einreichungshinweise f\u00fcr Gerichte, Kammern und \u00c4mter im Patentbereich.", + "gerichte.search.placeholder": "Suchen nach Name, Stadt, Typ...", + "gerichte.filter.type": "Typ:", + "gerichte.filter.country": "Land:", + "gerichte.filter.all": "Alle", + "gerichte.filter.de": "Deutschland", + "gerichte.filter.national": "National", + "gerichte.empty": "Keine Treffer.", + "gerichte.field.address": "Adresse", + "gerichte.field.contact": "Kontakt", + "gerichte.field.fax": "Fax", + "gerichte.field.filing": "Einreichung", + "gerichte.field.notes": "Praktische Hinweise", + "gerichte.field.hlContact": "HL-Ansprechpartner", + "gerichte.feedback.btn": "Korrektur vorschlagen", + "gerichte.feedback.title": "Korrektur vorschlagen", + "gerichte.feedback.court": "Gericht", + "gerichte.feedback.type": "Art der Anmerkung", + "gerichte.feedback.type.address": "Adresse / Kontaktdaten", + "gerichte.feedback.type.filing": "Einreichungshinweise", + "gerichte.feedback.type.notes": "Praktische Hinweise", + "gerichte.feedback.type.missing": "Fehlendes Gericht", + "gerichte.feedback.type.other": "Sonstiges", + "gerichte.feedback.message": "Nachricht", + "gerichte.feedback.cancel": "Abbrechen", + "gerichte.feedback.submit": "Absenden", + "gerichte.feedback.success": "Vielen Dank \u2014 Ihre R\u00fcckmeldung wurde gespeichert.", + "gerichte.feedback.error.required": "Bitte geben Sie eine Nachricht ein.", + "gerichte.feedback.error.generic": "Fehler beim Senden. Bitte versuchen Sie es erneut.", + + // Index \u2014 Gerichte card + "index.gerichte.title": "Gerichtsverzeichnis", + "index.gerichte.desc": "Gerichte, UPC-Kammern und Patent\u00e4mter auf einen Blick \u2014 mit Adressen, Einreichungshinweisen und Sprachen.", }, en: { @@ -317,6 +355,7 @@ const translations: Record> = { "nav.glossar": "Glossary", "nav.gebuehrentabellen": "Fee Schedules", "nav.checklisten": "Checklists", + "nav.gerichte": "Courts", "nav.logout": "Sign Out", // Footer @@ -603,6 +642,43 @@ const translations: Record> = { "checklisten.feedback.success": "Thank you for your feedback!", "checklisten.feedback.error.required": "Please enter a message.", "checklisten.feedback.error.generic": "Error submitting. Please try again.", + + // Gerichte + "gerichte.title": "Court Directory \u2014 patHoLo", + "gerichte.heading": "Court Directory", + "gerichte.subtitle": "Contacts, addresses, and filing details for courts, divisions, and offices relevant to patent practice.", + "gerichte.search.placeholder": "Search by name, city, type...", + "gerichte.filter.type": "Type:", + "gerichte.filter.country": "Country:", + "gerichte.filter.all": "All", + "gerichte.filter.de": "Germany", + "gerichte.filter.national": "National", + "gerichte.empty": "No matches found.", + "gerichte.field.address": "Address", + "gerichte.field.contact": "Contact", + "gerichte.field.fax": "Fax", + "gerichte.field.filing": "Filing", + "gerichte.field.notes": "Practical notes", + "gerichte.field.hlContact": "HL contact", + "gerichte.feedback.btn": "Suggest a correction", + "gerichte.feedback.title": "Suggest a correction", + "gerichte.feedback.court": "Court", + "gerichte.feedback.type": "Type of feedback", + "gerichte.feedback.type.address": "Address / contact details", + "gerichte.feedback.type.filing": "Filing details", + "gerichte.feedback.type.notes": "Practical notes", + "gerichte.feedback.type.missing": "Missing court", + "gerichte.feedback.type.other": "Other", + "gerichte.feedback.message": "Message", + "gerichte.feedback.cancel": "Cancel", + "gerichte.feedback.submit": "Submit", + "gerichte.feedback.success": "Thank you \u2014 your feedback has been recorded.", + "gerichte.feedback.error.required": "Please enter a message.", + "gerichte.feedback.error.generic": "Error submitting. Please try again.", + + // Index \u2014 Gerichte card + "index.gerichte.title": "Court Directory", + "index.gerichte.desc": "Courts, UPC divisions and patent offices at a glance \u2014 addresses, filing details, and languages.", }, }; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index df0541c..e890608 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -9,6 +9,7 @@ const ICON_BOOK = ' diff --git a/frontend/src/gerichte.tsx b/frontend/src/gerichte.tsx new file mode 100644 index 0000000..af5538b --- /dev/null +++ b/frontend/src/gerichte.tsx @@ -0,0 +1,120 @@ +import { h } from "./jsx"; +import { Sidebar } from "./components/Sidebar"; +import { Footer } from "./components/Footer"; + +export function renderGerichte(): string { + return "" + ( + + + + + Gerichtsverzeichnis — patHoLo + + + + + +
+
+
+
+
+
+

Gerichtsverzeichnis

+

+ Kontaktdaten, Adressen und Einreichungshinweise für Gerichte, Kammern und Ämter im Patentbereich. +

+
+ +
+
+ +
+
+ + + + + + +
+ +
+ Typ: +
+ + + + + +
+
+ +
+ Land: +
+ +
+
+
+ +
+ + + + {/* Feedback modal */} +