The global search palette emits /links?q=<title> when a user clicks a link
result, but /links had no search input — only category filter pills — so
the deep link silently landed on the unfiltered catalog.
Added a search input matching the glossary/courts pattern: live keystroke
filtering across title + DE/EN description + URL, combined with the
existing category filter, and ?q= URL prefill on init. Result count chip
("2 / 47") added next to the input for parity with the other catalogs.
i18n: links.search.placeholder added in DE + EN.
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
import { initI18n, t, getLang, onLangChange } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
interface LinkCategory {
|
|
id: string;
|
|
nameDE: string;
|
|
nameEN: string;
|
|
}
|
|
|
|
interface Link {
|
|
id: string;
|
|
category: string;
|
|
title: string;
|
|
url: string;
|
|
descDE: string;
|
|
descEN: string;
|
|
}
|
|
|
|
interface LinksData {
|
|
categories: LinkCategory[];
|
|
links: Link[];
|
|
}
|
|
|
|
const ICON_EXTERNAL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
|
const ICON_FEEDBACK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>';
|
|
|
|
let data: LinksData | null = null;
|
|
let activeCategory = "all";
|
|
let searchQuery = "";
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
initSearch();
|
|
loadLinks();
|
|
loadSuggestionCount();
|
|
setupSuggestModal();
|
|
setupFeedbackModal();
|
|
onLangChange(() => {
|
|
if (data) renderAll();
|
|
});
|
|
});
|
|
|
|
function initSearch() {
|
|
const input = document.getElementById("links-search") as HTMLInputElement | null;
|
|
if (!input) return;
|
|
input.addEventListener("input", () => {
|
|
searchQuery = input.value;
|
|
renderLinks();
|
|
});
|
|
// Honor `?q=` from the global search-palette deep links. renderLinks()
|
|
// runs after loadLinks() resolves and reads the module-level searchQuery.
|
|
const q = new URLSearchParams(location.search).get("q");
|
|
if (q) {
|
|
input.value = q;
|
|
searchQuery = q;
|
|
}
|
|
}
|
|
|
|
async function loadLinks() {
|
|
try {
|
|
const resp = await fetch("/api/links");
|
|
if (!resp.ok) return;
|
|
data = await resp.json();
|
|
renderAll();
|
|
} catch {
|
|
// Silently fail — page will show empty state
|
|
}
|
|
}
|
|
|
|
async function loadSuggestionCount() {
|
|
try {
|
|
const resp = await fetch("/api/links/suggestions/count");
|
|
if (!resp.ok) return;
|
|
const result = await resp.json();
|
|
const badge = document.getElementById("pending-badge");
|
|
if (badge && result.count > 0) {
|
|
badge.textContent = `${result.count} ${t("links.pending")}`;
|
|
badge.style.display = "";
|
|
}
|
|
} catch {
|
|
// Ignore — badge stays hidden
|
|
}
|
|
}
|
|
|
|
function renderAll() {
|
|
if (!data) return;
|
|
renderFilters();
|
|
renderLinks();
|
|
}
|
|
|
|
function renderFilters() {
|
|
if (!data) return;
|
|
const container = document.getElementById("category-filters");
|
|
if (!container) return;
|
|
|
|
const lang = getLang();
|
|
const allBtn = `<button class="links-filter-btn${activeCategory === "all" ? " active" : ""}" data-category="all" type="button">${t("links.filter.all")}</button>`;
|
|
const catBtns = data.categories.map((cat) => {
|
|
const label = lang === "en" ? cat.nameEN : cat.nameDE;
|
|
const active = activeCategory === cat.id ? " active" : "";
|
|
return `<button class="links-filter-btn${active}" data-category="${cat.id}" type="button">${label}</button>`;
|
|
});
|
|
|
|
container.innerHTML = allBtn + catBtns.join("");
|
|
|
|
container.querySelectorAll<HTMLButtonElement>(".links-filter-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
activeCategory = btn.dataset.category || "all";
|
|
renderAll();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderLinks() {
|
|
if (!data) return;
|
|
const grid = document.getElementById("links-grid");
|
|
if (!grid) return;
|
|
|
|
const lang = getLang();
|
|
const q = searchQuery.trim().toLowerCase();
|
|
const filtered = data.links.filter((l) => {
|
|
if (activeCategory !== "all" && l.category !== activeCategory) return false;
|
|
if (q) {
|
|
const hay = `${l.title} ${l.descDE} ${l.descEN} ${l.url}`.toLowerCase();
|
|
if (!hay.includes(q)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const count = document.getElementById("links-count");
|
|
if (count) count.textContent = `${filtered.length} / ${data.links.length}`;
|
|
|
|
if (filtered.length === 0) {
|
|
grid.innerHTML = `<p class="links-empty">${t("links.empty")}</p>`;
|
|
return;
|
|
}
|
|
|
|
// Group by category for display
|
|
const grouped = new Map<string, Link[]>();
|
|
for (const link of filtered) {
|
|
const list = grouped.get(link.category) || [];
|
|
list.push(link);
|
|
grouped.set(link.category, list);
|
|
}
|
|
|
|
let html = "";
|
|
for (const [catId, links] of grouped) {
|
|
const cat = data.categories.find((c) => c.id === catId);
|
|
const catName = cat ? (lang === "en" ? cat.nameEN : cat.nameDE) : catId;
|
|
|
|
if (activeCategory === "all") {
|
|
html += `<h2 class="links-category-heading">${catName}</h2>`;
|
|
}
|
|
|
|
html += `<div class="links-category-grid">`;
|
|
for (const link of links) {
|
|
const desc = lang === "en" ? link.descEN : link.descDE;
|
|
const isPlaceholder = link.url === "#";
|
|
const target = isPlaceholder ? "" : ' target="_blank" rel="noopener"';
|
|
const placeholderClass = isPlaceholder ? " link-card-placeholder" : "";
|
|
html += `<a href="${link.url}" class="link-card${placeholderClass}"${target}>
|
|
<div class="link-card-body">
|
|
<div class="link-card-title">
|
|
<span>${link.title}</span>
|
|
${isPlaceholder ? "" : `<span class="link-card-external">${ICON_EXTERNAL}</span>`}
|
|
</div>
|
|
<p class="link-card-desc">${desc}</p>
|
|
</div>
|
|
<button class="link-card-feedback" type="button" data-link-id="${link.id}" title="Feedback">
|
|
${ICON_FEEDBACK}
|
|
</button>
|
|
</a>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
grid.innerHTML = html;
|
|
|
|
// Attach feedback handlers
|
|
grid.querySelectorAll<HTMLButtonElement>(".link-card-feedback").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openFeedbackModal(btn.dataset.linkId || "");
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Suggest Modal ---
|
|
|
|
function setupSuggestModal() {
|
|
const modal = document.getElementById("suggest-modal");
|
|
const btn = document.getElementById("suggest-btn");
|
|
const cancel = document.getElementById("suggest-cancel");
|
|
const form = document.getElementById("suggest-form") as HTMLFormElement | null;
|
|
|
|
if (!btn || !modal || !form) return;
|
|
|
|
btn.addEventListener("click", () => {
|
|
populateCategorySelect("suggest-category");
|
|
modal.style.display = "";
|
|
});
|
|
|
|
cancel?.addEventListener("click", () => {
|
|
modal.style.display = "none";
|
|
form.reset();
|
|
clearMessage("suggest-message");
|
|
});
|
|
|
|
modal.addEventListener("click", (e) => {
|
|
if (e.target === modal) {
|
|
modal.style.display = "none";
|
|
form.reset();
|
|
clearMessage("suggest-message");
|
|
}
|
|
});
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const title = (document.getElementById("suggest-title") as HTMLInputElement).value.trim();
|
|
const url = (document.getElementById("suggest-url") as HTMLInputElement).value.trim();
|
|
const category = (document.getElementById("suggest-category") as HTMLSelectElement).value;
|
|
const description = (document.getElementById("suggest-desc") as HTMLTextAreaElement).value.trim();
|
|
|
|
try {
|
|
const resp = await fetch("/api/links/suggest", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, url, category, description }),
|
|
});
|
|
if (resp.ok) {
|
|
showMessage("suggest-message", t("links.suggest.success"), "success");
|
|
form.reset();
|
|
setTimeout(() => {
|
|
modal.style.display = "none";
|
|
clearMessage("suggest-message");
|
|
}, 2000);
|
|
loadSuggestionCount();
|
|
} else {
|
|
showMessage("suggest-message", t("links.suggest.error"), "error");
|
|
}
|
|
} catch {
|
|
showMessage("suggest-message", t("links.suggest.error"), "error");
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Feedback Modal ---
|
|
|
|
function openFeedbackModal(linkId: string) {
|
|
const modal = document.getElementById("feedback-modal");
|
|
const input = document.getElementById("feedback-link-id") as HTMLInputElement | null;
|
|
if (!modal || !input) return;
|
|
input.value = linkId;
|
|
modal.style.display = "";
|
|
}
|
|
|
|
function setupFeedbackModal() {
|
|
const modal = document.getElementById("feedback-modal");
|
|
const cancel = document.getElementById("feedback-cancel");
|
|
const form = document.getElementById("feedback-form") as HTMLFormElement | null;
|
|
|
|
if (!modal || !form) return;
|
|
|
|
cancel?.addEventListener("click", () => {
|
|
modal.style.display = "none";
|
|
form.reset();
|
|
clearMessage("feedback-message-status");
|
|
});
|
|
|
|
modal.addEventListener("click", (e) => {
|
|
if (e.target === modal) {
|
|
modal.style.display = "none";
|
|
form.reset();
|
|
clearMessage("feedback-message-status");
|
|
}
|
|
});
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const linkId = (document.getElementById("feedback-link-id") as HTMLInputElement).value;
|
|
const type = (document.getElementById("feedback-type") as HTMLSelectElement).value;
|
|
const message = (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim();
|
|
|
|
try {
|
|
const resp = await fetch("/api/links/feedback", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ linkId, type, message }),
|
|
});
|
|
if (resp.ok) {
|
|
showMessage("feedback-message-status", t("links.feedback.success"), "success");
|
|
form.reset();
|
|
setTimeout(() => {
|
|
modal.style.display = "none";
|
|
clearMessage("feedback-message-status");
|
|
}, 2000);
|
|
} else {
|
|
showMessage("feedback-message-status", t("links.suggest.error"), "error");
|
|
}
|
|
} catch {
|
|
showMessage("feedback-message-status", t("links.suggest.error"), "error");
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
function populateCategorySelect(selectId: string) {
|
|
if (!data) return;
|
|
const select = document.getElementById(selectId) as HTMLSelectElement | null;
|
|
if (!select) return;
|
|
const lang = getLang();
|
|
select.innerHTML = data.categories.map((cat) => {
|
|
const label = lang === "en" ? cat.nameEN : cat.nameDE;
|
|
return `<option value="${cat.id}">${label}</option>`;
|
|
}).join("");
|
|
}
|
|
|
|
function showMessage(id: string, text: string, type: "success" | "error") {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = text;
|
|
el.className = `form-message form-message-${type}`;
|
|
}
|
|
|
|
function clearMessage(id: string) {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = "";
|
|
el.className = "form-message";
|
|
}
|