Files
paliad/frontend/src/client/checklists-detail.ts
mAi a93277a072 feat(checklists): t-paliad-225 Slice B frontend — share modal + admin promote/demote on detail page
m/paliad#61 Slice B frontend pass.

Detail page (/checklists/{slug}) gains:
- Provenance line ("Erstellt von <author>") for authored templates,
  populated from the catalog response's owner_display_name.
- Owner action buttons: Bearbeiten (links to
  /checklists/templates/{slug}/edit per the Slice A hotfix), Teilen,
  Löschen. Reveal driven by /api/me email match against the catalog
  response's owner_email.
- global_admin action buttons: "Als Firmen-Vorlage hinterlegen"
  (promote) when visibility != 'global'; "Aus Katalog entfernen"
  (demote) when visibility == 'global'. Reveal driven by /api/me
  global_role.

Share modal:
- Single modal with a kind-picker (Kollege / Office / Dezernat /
  Projekt) and a matching select per kind — sections toggle on the
  active kind.
- Recipient pickers populated from /api/users, /api/partner-units,
  /api/projects (loaded in parallel on open). Office options use the
  canonical 8-key set from internal/offices.
- Existing grants surface in a list under the form with per-row
  Entfernen buttons; Revoke confirms before DELETE.
- Errors surface inline (recipient-required, generic share failure).

i18n: 32 new keys per language (DE+EN) under checklisten.share.*
and checklisten.detail.promote/demote/delete.*. Total 2653 keys.

Build hygiene: go build/vet/test ./internal/... + ./cmd/server/ all
green; bun run build clean.
2026-05-20 15:38:43 +02:00

722 lines
25 KiB
TypeScript

import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface ChecklistItem {
labelDE: string;
labelEN: string;
noteDE?: string;
noteEN?: string;
rule?: string;
}
interface ChecklistGroup {
titleDE: string;
titleEN: string;
items: ChecklistItem[];
}
interface Checklist {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE: string;
descriptionEN: string;
regime: string;
courtDE: string;
courtEN: string;
deadlineDE?: string;
deadlineEN?: string;
referenceDE?: string;
referenceEN?: string;
groups: ChecklistGroup[];
// Slice B fields — present on authored entries via the merged
// catalog response. 'static' templates don't carry these.
origin?: "static" | "authored";
visibility?: string;
owner_email?: string;
owner_display_name?: string;
}
interface Me {
id: string;
email: string;
display_name: string;
global_role?: string;
}
interface UserSummary {
id: string;
email: string;
display_name: string;
}
interface PartnerUnit {
id: string;
name: string;
}
interface Share {
id: string;
checklist_id: string;
recipient_kind: "user" | "office" | "partner_unit" | "project";
recipient_label: 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;
}
interface AkteSummary {
id: string;
reference?: string | null;
title: string;
path: string;
}
let template: Checklist | null = null;
let instances: ChecklistInstance[] = [];
let projects: AkteSummary[] = [];
let totalItems = 0;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function templateSlug(): string {
// /checklisten/{slug}
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[1] ?? "";
}
async function loadTemplate() {
const slug = templateSlug();
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}`);
if (!resp.ok) {
document.title = "404 — Paliad";
document.getElementById("checklist-title")!.textContent = t("checklisten.notfound");
return;
}
template = await resp.json();
if (template) {
totalItems = template.groups.reduce((n, g) => n + g.items.length, 0);
}
renderHeader();
}
async function loadInstances() {
const slug = templateSlug();
try {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`);
if (!resp.ok) {
instances = [];
} else {
instances = await resp.json();
}
} catch {
instances = [];
}
renderInstances();
}
async function loadAkten() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) projects = await resp.json();
} catch {
projects = [];
}
renderAkteOptions();
}
function renderHeader() {
if (!template) return;
const isEN = getLang() === "en";
const title = isEN ? template.titleEN : template.titleDE;
const desc = isEN ? template.descriptionEN : template.descriptionDE;
const court = isEN ? template.courtEN : template.courtDE;
const deadline = isEN ? template.deadlineEN : template.deadlineDE;
const reference = isEN ? template.referenceEN : template.referenceDE;
document.title = `${title} — Paliad`;
document.getElementById("checklist-title")!.textContent = title;
document.getElementById("checklist-subtitle")!.textContent = desc;
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
const deadlineLabel = isEN ? "Deadline" : "Deadline";
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
const regimeLabel = isEN ? "Regime" : "Bereich";
const itemsLabel = isEN ? "Items" : "Punkte";
const parts: string[] = [];
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(template.regime)}">${esc(template.regime)}</span></dd></div>`);
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
if (deadline) {
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
}
if (reference) {
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
}
parts.push(`<div class="checklist-meta-item"><dt>${itemsLabel}</dt><dd>${totalItems}</dd></div>`);
document.getElementById("checklist-meta")!.innerHTML = parts.join("");
}
function progress(inst: ChecklistInstance): { done: number; pct: number } {
const done = Object.values(inst.state || {}).filter(Boolean).length;
const pct = totalItems === 0 ? 0 : Math.round((done / totalItems) * 100);
return { done, pct };
}
function formatDate(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
const lang = getLang();
return d.toLocaleDateString(lang === "en" ? "en-GB" : "de-DE", {
year: "numeric", month: "2-digit", day: "2-digit",
});
}
function renderInstances() {
const loading = document.getElementById("instances-loading")!;
const empty = document.getElementById("instances-empty")!;
const wrap = document.getElementById("instances-tablewrap")!;
const body = document.getElementById("instances-body")!;
loading.style.display = "none";
if (instances.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
const isEN = getLang() === "en";
const deleteLabel = isEN ? "Delete" : "Löschen";
const openLabel = isEN ? "Öffnen" : "Öffnen";
const personalLabel = isEN ? "personal" : "persönlich";
body.innerHTML = instances.map((inst) => {
const { done, pct } = progress(inst);
const akteCell = inst.project_id && inst.project_reference
? `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project-link">${esc(inst.project_reference)}</a>`
: `<span class="entity-muted">${personalLabel}</span>`;
return `<tr data-id="${esc(inst.id)}" class="checklist-instance-row">
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></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} / ${totalItems}</span>
</div>
</td>
<td>${akteCell}</td>
<td>${esc(formatDate(inst.created_at))}</td>
<td class="checklist-instance-actions">
<a class="btn-small btn-ghost" href="/checklists/instances/${esc(inst.id)}">${esc(openLabel)}</a>
<button type="button" class="btn-small btn-ghost btn-delete-instance" data-id="${esc(inst.id)}" data-name="${esc(inst.name)}">${esc(deleteLabel)}</button>
</td>
</tr>`;
}).join("");
body.querySelectorAll<HTMLButtonElement>(".btn-delete-instance").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const id = btn.dataset.id!;
const name = btn.dataset.name ?? "";
const msg = (t("checklisten.instances.delete.confirm") || "").replace("{name}", name);
if (!confirm(msg)) return;
void deleteInstance(id);
});
});
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button")) return;
window.location.href = `/checklists/instances/${id}`;
});
});
}
function renderAkteOptions() {
const sel = document.getElementById("new-instance-project") as HTMLSelectElement;
if (!sel) return;
const none = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (none) sel.appendChild(none);
projects.forEach((a) => {
const opt = document.createElement("option");
opt.value = a.id;
opt.textContent = `${projectIndent(a.path)}${a.reference || ""}${a.title}`;
sel.appendChild(opt);
});
}
function initNewInstance() {
const modal = document.getElementById("new-instance-modal")!;
const form = document.getElementById("new-instance-form")! as HTMLFormElement;
const msg = document.getElementById("new-instance-msg")!;
const nameInput = document.getElementById("new-instance-name") as HTMLInputElement;
const akteSel = document.getElementById("new-instance-project") as HTMLSelectElement;
const open = () => {
msg.textContent = "";
msg.className = "form-msg";
nameInput.value = "";
akteSel.value = "";
modal.style.display = "flex";
nameInput.focus();
};
const close = () => { modal.style.display = "none"; };
document.getElementById("btn-new-instance")!.addEventListener("click", open);
document.getElementById("new-instance-close")!.addEventListener("click", close);
document.getElementById("new-instance-cancel")!.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
form.addEventListener("submit", async (e) => {
e.preventDefault();
const name = nameInput.value.trim();
if (!name) {
msg.textContent = t("checklisten.newInstance.error.name");
msg.className = "form-msg form-msg-error";
return;
}
const akteID = akteSel.value || null;
const payload: { name: string; project_id?: string } = { name };
if (akteID) payload.project_id = akteID;
const slug = templateSlug();
const submitBtn = form.querySelector(".btn-primary") as HTMLButtonElement;
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.newInstance.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
const created = await resp.json() as ChecklistInstance;
window.location.href = `/checklists/instances/${encodeURIComponent(created.id)}`;
} catch {
msg.textContent = t("checklisten.newInstance.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
async function deleteInstance(id: string) {
try {
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
alert(t("checklisten.instances.delete.error"));
return;
}
instances = instances.filter((i) => i.id !== id);
renderInstances();
} catch {
alert(t("checklisten.instances.delete.error"));
}
}
function initFeedback() {
const modal = document.getElementById("feedback-modal")!;
const form = document.getElementById("feedback-form")!;
const msg = document.getElementById("feedback-msg")!;
document.getElementById("btn-feedback")!.addEventListener("click", () => {
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
});
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!template) return;
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
const payload = {
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
checklist: template.slug,
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
};
if (!payload.message) {
msg.textContent = t("checklisten.feedback.error.required");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
const resp = await fetch("/api/checklists/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.feedback.success");
msg.className = "form-msg form-msg-success";
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
setTimeout(() => { modal.style.display = "none"; }, 1500);
} catch {
msg.textContent = t("checklisten.feedback.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
function rerenderAll() {
renderHeader();
renderInstances();
}
// --- Slice B: owner actions + admin promote + share modal ----------------
let me: Me | null = null;
let isOwner = false;
let isAdmin = false;
let shareUsers: UserSummary[] = [];
let sharePartnerUnits: PartnerUnit[] = [];
let shareProjects: AkteSummary[] = [];
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
async function loadMe(): Promise<Me | null> {
try {
const resp = await fetch("/api/me");
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
function templateOriginInfo() {
return template as unknown as {
origin?: string;
visibility?: string;
owner_email?: string;
owner_display_name?: string;
} | null;
}
function applyOwnerControls() {
const info = templateOriginInfo();
const isAuthored = info?.origin === "authored";
const provenance = document.getElementById("checklist-provenance")!;
if (isAuthored && info?.owner_display_name) {
provenance.style.display = "";
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
} else {
provenance.style.display = "none";
}
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
isAdmin = !!(me && me.global_role === "global_admin");
const ownerOnly = (id: string, show: boolean) => {
const el = document.getElementById(id);
if (el) (el as HTMLElement).style.display = show ? "" : "none";
};
if (template) {
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
"href",
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
);
}
ownerOnly("btn-edit-template", isOwner);
ownerOnly("btn-share-template", isOwner);
ownerOnly("btn-delete-template", isOwner);
// Admin promote/demote — only when an authored template is visible to
// an admin, and only the appropriate one for the current visibility.
if (isAuthored && isAdmin) {
const isGlobal = info?.visibility === "global";
ownerOnly("btn-promote-template", !isGlobal);
ownerOnly("btn-demote-template", isGlobal);
} else {
ownerOnly("btn-promote-template", false);
ownerOnly("btn-demote-template", false);
}
}
function initOwnerActions() {
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
if (!template) return;
const isEN = getLang() === "en";
const title = isEN ? template.titleEN : template.titleDE;
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
if (!window.confirm(msg)) return;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
if (!resp.ok) {
window.alert(t("checklisten.detail.delete.error"));
return;
}
window.location.href = "/checklists?tab=mine";
});
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
if (!template) return;
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: "firm" }),
});
if (!resp.ok) {
window.alert(t("checklisten.detail.promote.error"));
return;
}
window.location.reload();
});
}
async function loadSharePickerData() {
// Fire all three lookups in parallel — the share modal needs all of
// them but doesn't depend on their order.
try {
const [usersResp, unitsResp, projectsResp] = await Promise.all([
fetch("/api/users"),
fetch("/api/partner-units"),
fetch("/api/projects"),
]);
shareUsers = usersResp.ok ? await usersResp.json() : [];
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
} catch {
/* leave whatever loaded */
}
populateSharePickerOptions();
}
function populateSharePickerOptions() {
const userSel = document.getElementById("share-user") as HTMLSelectElement;
if (userSel) {
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareUsers
.slice()
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.forEach((u) => {
if (me && u.id === me.id) return; // can't share with self
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = `${u.display_name} (${u.email})`;
userSel.appendChild(opt);
});
}
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
if (officeSel) {
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
officeKeys.forEach((k) => {
const opt = document.createElement("option");
opt.value = k;
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
officeSel.appendChild(opt);
});
}
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
if (puSel) {
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
sharePartnerUnits
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((u) => {
const opt = document.createElement("option");
opt.value = u.id;
opt.textContent = u.name;
puSel.appendChild(opt);
});
}
const prSel = document.getElementById("share-project") as HTMLSelectElement;
if (prSel) {
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
shareProjects
.slice()
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
.forEach((p) => {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = `${p.reference || ""}${p.title}`;
prSel.appendChild(opt);
});
}
}
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
activeShareKind = kind;
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
p.classList.toggle("active", p.dataset.kind === kind);
});
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
s.style.display = s.dataset.kind === kind ? "" : "none";
});
}
function initShareModal() {
const modal = document.getElementById("share-modal")!;
const msg = document.getElementById("share-msg")!;
const close = () => { modal.style.display = "none"; };
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
if (!template) return;
msg.textContent = "";
msg.className = "form-msg";
switchShareKind("user");
modal.style.display = "flex";
await loadSharePickerData();
await renderGrants();
});
document.getElementById("share-close")?.addEventListener("click", close);
document.getElementById("share-cancel")?.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
if (!btn) return;
switchShareKind(btn.dataset.kind as typeof activeShareKind);
});
document.getElementById("share-submit")?.addEventListener("click", async () => {
if (!template) return;
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
switch (activeShareKind) {
case "user": {
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_user_id"] = v;
break;
}
case "office": {
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_office"] = v;
break;
}
case "partner_unit": {
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_partner_unit_id"] = v;
break;
}
case "project": {
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
input["recipient_project_id"] = v;
break;
}
}
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!resp.ok) {
let errMsg = t("checklisten.share.error.generic");
try {
const j = await resp.json();
if (j?.error) errMsg = j.error;
} catch { /* keep generic */ }
msg.textContent = errMsg;
msg.className = "form-msg form-msg-error";
return;
}
msg.textContent = t("checklisten.share.success");
msg.className = "form-msg form-msg-success";
await renderGrants();
});
}
async function renderGrants() {
if (!template) return;
const list = document.getElementById("share-grants-list")!;
const empty = document.getElementById("share-grants-empty")!;
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
const rows: Share[] = resp.ok ? await resp.json() : [];
if (rows.length === 0) {
list.innerHTML = "";
list.appendChild(empty);
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = rows.map((s) => {
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
return `<li class="share-grant-row" data-id="${esc(s.id)}">
<span class="share-grant-kind">${kindLabel}</span>
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
</li>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
window.alert(t("checklisten.share.grants.revoke.error"));
return;
}
await renderGrants();
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initNewInstance();
initFeedback();
initOwnerActions();
initShareModal();
onLangChange(rerenderAll);
void (async () => {
me = await loadMe();
await loadTemplate();
applyOwnerControls();
})();
void loadInstances();
void loadAkten();
});