m/paliad#61 Slice C frontend pass. Discovery (Geteilte Vorlagen): - New 4th tab on /checklists between "Meine Vorlagen" and "Vorhandene Instanzen". Filters the merged catalog response to authored entries not owned by the caller (firm-visible OR globally-promoted OR share-recipient). Tab state round-trips via ?tab=gallery. - Regime filter pills (UPC / DE / EPA / OTHER) operate independently from the main Vorlagen tab. - Cards show regime badge, item count, author line, visibility chip. - Self-filter relies on /api/me email match — loadMe() fires once on page boot and is idempotent. Versioning UI on /checklists/instances/{id}: - "Vorlage aktualisiert" badge appears when the instance's template_version is known AND lags the live template version (only for authored templates; static templates never bump). Shows "v{from} → v{to}" delta. - "Änderungen anzeigen" button opens a diff modal that compares the instance's template_snapshot against the live template body. Item-level grouping by (section title, item label). Surfaces added / removed / changed items with localised section labels. Empty state when only metadata changed. i18n: 13 new keys per language (DE + EN) under checklisten.tab.gallery, checklisten.gallery.*, checklisten.filter.other, and checklisten.instance.{outdated,diff}.*. Total 2666 keys. Build hygiene: bun run build clean; i18n scan clean. Go build/vet/test + TestBootSmoke ./cmd/server/ all green.
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
interface ChecklistSummary {
|
|
slug: string;
|
|
titleDE: string;
|
|
titleEN: string;
|
|
descriptionDE: string;
|
|
descriptionEN: string;
|
|
regime: string;
|
|
courtDE: string;
|
|
courtEN: string;
|
|
itemCount: number;
|
|
origin?: "static" | "authored";
|
|
visibility?: string;
|
|
owner_email?: string;
|
|
owner_display_name?: string;
|
|
}
|
|
|
|
interface MyChecklist {
|
|
id: string;
|
|
slug: string;
|
|
owner_id: string;
|
|
title: string;
|
|
description: string;
|
|
regime: string;
|
|
court: string;
|
|
reference: string;
|
|
deadline: string;
|
|
lang: string;
|
|
visibility: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface ChecklistInstance {
|
|
id: string;
|
|
template_slug: string;
|
|
name: string;
|
|
project_id?: string | null;
|
|
state: Record<string, boolean>;
|
|
created_by: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
project_reference?: string | null;
|
|
project_title?: string | null;
|
|
}
|
|
|
|
type TabId = "templates" | "mine" | "gallery" | "instances";
|
|
|
|
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
|
|
|
|
let allChecklists: ChecklistSummary[] = [];
|
|
let activeRegime = "all";
|
|
let galleryRegime = "all";
|
|
let allInstances: ChecklistInstance[] = [];
|
|
let templatesBySlug: Record<string, ChecklistSummary> = {};
|
|
let instancesLoaded = false;
|
|
let myTemplates: MyChecklist[] = [];
|
|
let myTemplatesLoaded = false;
|
|
let galleryLoaded = false;
|
|
let me: { id: string; email: string } | null = null;
|
|
let activeTab: TabId = "templates";
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function escAttr(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
}
|
|
|
|
function parseTab(): TabId {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const candidate = params.get("tab");
|
|
if (candidate && (VALID_TABS as string[]).includes(candidate)) {
|
|
return candidate as TabId;
|
|
}
|
|
return "templates";
|
|
}
|
|
|
|
async function loadTemplates() {
|
|
const resp = await fetch("/api/checklists");
|
|
if (!resp.ok) return;
|
|
allChecklists = await resp.json();
|
|
templatesBySlug = {};
|
|
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
|
|
renderTemplates();
|
|
}
|
|
|
|
function renderTemplates() {
|
|
const grid = document.getElementById("checklist-grid")!;
|
|
const isEN = getLang() === "en";
|
|
|
|
const filtered = activeRegime === "all"
|
|
? allChecklists
|
|
: allChecklists.filter((c) => c.regime === activeRegime);
|
|
|
|
if (filtered.length === 0) {
|
|
grid.innerHTML = `<p class="checklist-empty" data-i18n="checklisten.empty">${esc(t("checklisten.empty"))}</p>`;
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = filtered.map((c) => {
|
|
const title = isEN ? c.titleEN : c.titleDE;
|
|
const desc = isEN ? c.descriptionEN : c.descriptionDE;
|
|
const court = isEN ? c.courtEN : c.courtDE;
|
|
const itemsLabel = isEN ? "items" : "Punkte";
|
|
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
|
|
<div class="checklist-card-top">
|
|
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
|
|
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
|
|
</div>
|
|
<h2 class="checklist-card-title">${esc(title)}</h2>
|
|
<p class="checklist-card-desc">${esc(desc)}</p>
|
|
<p class="checklist-card-court">${esc(court)}</p>
|
|
</a>`;
|
|
}).join("");
|
|
}
|
|
|
|
function initFilters() {
|
|
const container = document.getElementById("checklist-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");
|
|
activeRegime = btn.dataset.regime ?? "all";
|
|
renderTemplates();
|
|
});
|
|
}
|
|
|
|
async function loadInstances() {
|
|
if (instancesLoaded) return;
|
|
instancesLoaded = true;
|
|
// Templates may not be loaded yet if the user lands directly on
|
|
// ?tab=instances — fetch in parallel so the join below has names.
|
|
const [instResp, tplResp] = await Promise.all([
|
|
fetch("/api/checklist-instances"),
|
|
allChecklists.length === 0 ? fetch("/api/checklists") : Promise.resolve(null),
|
|
]);
|
|
if (instResp.ok) {
|
|
allInstances = (await instResp.json()) ?? [];
|
|
} else {
|
|
allInstances = [];
|
|
}
|
|
if (tplResp && tplResp.ok) {
|
|
allChecklists = (await tplResp.json()) ?? [];
|
|
templatesBySlug = {};
|
|
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
|
|
}
|
|
renderInstances();
|
|
}
|
|
|
|
function renderInstances() {
|
|
const loading = document.getElementById("checklists-instances-loading")!;
|
|
const empty = document.getElementById("checklists-instances-empty")!;
|
|
const wrap = document.getElementById("checklists-instances-tablewrap")!;
|
|
const body = document.getElementById("checklists-instances-body")!;
|
|
|
|
loading.style.display = "none";
|
|
|
|
if (allInstances.length === 0) {
|
|
empty.style.display = "";
|
|
wrap.style.display = "none";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
wrap.style.display = "";
|
|
|
|
const isEN = getLang() === "en";
|
|
const fmtDate = (iso: string) => {
|
|
const d = new Date(iso);
|
|
if (isNaN(d.getTime())) return "";
|
|
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
|
year: "numeric", month: "2-digit", day: "2-digit",
|
|
});
|
|
};
|
|
|
|
body.innerHTML = allInstances.map((inst) => {
|
|
const tpl = templatesBySlug[inst.template_slug];
|
|
const tplName = tpl
|
|
? (isEN ? tpl.titleEN : tpl.titleDE)
|
|
: inst.template_slug;
|
|
const total = tpl ? tpl.itemCount : 0;
|
|
const done = Object.values(inst.state || {}).filter(Boolean).length;
|
|
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
|
|
|
let projectCell: string;
|
|
if (inst.project_id && inst.project_title) {
|
|
const ref = inst.project_reference ? esc(inst.project_reference) : "";
|
|
const title = esc(inst.project_title);
|
|
const refPart = ref ? `<span class="entity-ref">${ref}</span> ` : "";
|
|
projectCell = `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project">${refPart}${title}</a>`;
|
|
} else {
|
|
projectCell = `<span class="form-hint" data-i18n="checklisten.instances.all.personal">Persönlich</span>`;
|
|
}
|
|
|
|
return `<tr class="checklist-instance-row" data-id="${esc(inst.id)}">
|
|
<td>${esc(tplName)}</td>
|
|
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></td>
|
|
<td>${projectCell}</td>
|
|
<td>
|
|
<div class="checklist-progress-inline">
|
|
<div class="checklist-progress-bar">
|
|
<div class="checklist-progress-fill" style="width:${pct}%"></div>
|
|
</div>
|
|
<span class="checklist-progress-label">${done} / ${total}</span>
|
|
</div>
|
|
</td>
|
|
<td>${esc(fmtDate(inst.created_at))}</td>
|
|
</tr>`;
|
|
}).join("");
|
|
|
|
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
|
|
const id = row.dataset.id!;
|
|
row.addEventListener("click", (e) => {
|
|
// Let inner links (project, instance name) handle their own navigation.
|
|
if ((e.target as HTMLElement).closest("a")) return;
|
|
window.location.href = `/checklists/instances/${id}`;
|
|
});
|
|
});
|
|
}
|
|
|
|
function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
|
activeTab = tab;
|
|
document.querySelectorAll<HTMLElement>("#checklists-tabs .entity-tab").forEach((el) => {
|
|
el.classList.toggle("active", el.dataset.tab === tab);
|
|
});
|
|
document.querySelectorAll<HTMLElement>(".entity-tab-panel").forEach((el) => {
|
|
el.style.display = el.id === `tab-${tab}` ? "" : "none";
|
|
});
|
|
if (opts.pushHistory ?? true) {
|
|
let newURL = "/checklists";
|
|
if (tab === "instances") newURL = "/checklists?tab=instances";
|
|
if (tab === "mine") newURL = "/checklists?tab=mine";
|
|
if (tab === "gallery") newURL = "/checklists?tab=gallery";
|
|
if (window.location.pathname + window.location.search !== newURL) {
|
|
window.history.replaceState({}, "", newURL);
|
|
}
|
|
}
|
|
if (tab === "instances") {
|
|
void loadInstances();
|
|
}
|
|
if (tab === "mine") {
|
|
void loadMyTemplates();
|
|
}
|
|
if (tab === "gallery") {
|
|
void loadGallery();
|
|
}
|
|
}
|
|
|
|
async function loadGallery(force = false) {
|
|
if (galleryLoaded && !force) return;
|
|
galleryLoaded = true;
|
|
// /api/checklists already returns the merged catalog; the gallery
|
|
// filter just narrows to non-static + non-owned + non-private.
|
|
if (allChecklists.length === 0) {
|
|
await loadTemplates();
|
|
}
|
|
renderGallery();
|
|
}
|
|
|
|
function renderGallery() {
|
|
const loading = document.getElementById("checklists-gallery-loading")!;
|
|
const empty = document.getElementById("checklists-gallery-empty")!;
|
|
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
|
|
|
|
loading.style.display = "none";
|
|
|
|
const visible = allChecklists.filter((c) => {
|
|
if (c.origin !== "authored") return false;
|
|
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
|
|
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
|
|
return true;
|
|
});
|
|
|
|
if (visible.length === 0) {
|
|
empty.style.display = "";
|
|
grid.style.display = "none";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
grid.style.display = "";
|
|
|
|
const isEN = getLang() === "en";
|
|
grid.innerHTML = visible.map((c) => {
|
|
const title = isEN ? c.titleEN : c.titleDE;
|
|
const desc = isEN ? c.descriptionEN : c.descriptionDE;
|
|
const court = isEN ? c.courtEN : c.courtDE;
|
|
const itemsLabel = isEN ? "items" : "Punkte";
|
|
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
|
|
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
|
|
const authorLine = c.owner_display_name
|
|
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
|
|
: "";
|
|
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
|
|
<div class="checklist-card-top">
|
|
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
|
|
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
|
|
</div>
|
|
<h2 class="checklist-card-title">${esc(title)}</h2>
|
|
<p class="checklist-card-desc">${esc(desc)}</p>
|
|
<p class="checklist-card-court">${esc(court)}</p>
|
|
${authorLine}
|
|
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
|
|
</a>`;
|
|
}).join("");
|
|
}
|
|
|
|
function initGalleryFilters() {
|
|
const container = document.getElementById("checklist-gallery-filters");
|
|
if (!container) return;
|
|
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");
|
|
galleryRegime = btn.dataset.regime ?? "all";
|
|
renderGallery();
|
|
});
|
|
}
|
|
|
|
async function loadMe() {
|
|
try {
|
|
const resp = await fetch("/api/me");
|
|
if (resp.ok) me = await resp.json();
|
|
} catch { /* leave me=null */ }
|
|
}
|
|
|
|
async function loadMyTemplates(force = false) {
|
|
if (myTemplatesLoaded && !force) return;
|
|
myTemplatesLoaded = true;
|
|
const resp = await fetch("/api/checklists/templates/mine");
|
|
if (!resp.ok) {
|
|
myTemplates = [];
|
|
} else {
|
|
myTemplates = (await resp.json()) ?? [];
|
|
}
|
|
renderMyTemplates();
|
|
}
|
|
|
|
function renderMyTemplates() {
|
|
const loading = document.getElementById("checklists-mine-loading")!;
|
|
const empty = document.getElementById("checklists-mine-empty")!;
|
|
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
|
|
|
|
loading.style.display = "none";
|
|
|
|
if (myTemplates.length === 0) {
|
|
empty.style.display = "";
|
|
grid.style.display = "none";
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
grid.style.display = "";
|
|
|
|
grid.innerHTML = myTemplates.map((tpl) => {
|
|
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
|
|
const visLabel = esc(t(visKey as never) || tpl.visibility);
|
|
const titleSafe = esc(tpl.title);
|
|
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
|
|
<div class="checklist-card-top">
|
|
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
|
|
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
|
|
</div>
|
|
<h2 class="checklist-card-title">
|
|
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
|
|
</h2>
|
|
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
|
|
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
|
|
<div class="checklist-card-actions">
|
|
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
|
|
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">Löschen</button>
|
|
</div>
|
|
</article>`;
|
|
}).join("");
|
|
|
|
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
|
|
btn.addEventListener("click", async (e) => {
|
|
e.preventDefault();
|
|
const slug = btn.dataset.slug!;
|
|
const title = btn.dataset.title || slug;
|
|
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
|
|
if (!window.confirm(msg)) return;
|
|
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
|
if (!resp.ok) {
|
|
window.alert(t("checklisten.mine.delete.error"));
|
|
return;
|
|
}
|
|
await loadMyTemplates(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
function initTabs() {
|
|
document.querySelectorAll<HTMLAnchorElement>("#checklists-tabs .entity-tab").forEach((tab) => {
|
|
tab.addEventListener("click", (e) => {
|
|
// Let middle-click / cmd-click open in new tab via the real href.
|
|
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
e.preventDefault();
|
|
const id = tab.dataset.tab as TabId;
|
|
if (VALID_TABS.includes(id)) showTab(id);
|
|
});
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
initFilters();
|
|
initGalleryFilters();
|
|
initTabs();
|
|
onLangChange(() => {
|
|
renderTemplates();
|
|
if (instancesLoaded) renderInstances();
|
|
if (myTemplatesLoaded) renderMyTemplates();
|
|
if (galleryLoaded) renderGallery();
|
|
});
|
|
void loadMe();
|
|
void loadTemplates();
|
|
showTab(parseTab(), { pushHistory: false });
|
|
});
|