Files
paliad/frontend/src/client/sidebar.ts
mAi 228ae1b263 mAi: #85 - sidebar scroll position persists across nav
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.

sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
2026-05-25 14:03:03 +02:00

754 lines
31 KiB
TypeScript

// Sidebar client-side behavior
// Hover expand with delay, pin toggle, mobile hamburger
import { initGlobalSearch } from "./search";
import { getChangelogSeen } from "./changelog-seen";
import { cycleTheme, getThemePref, onThemeChange, type ThemePref } from "./theme";
import { t } from "./i18n";
const PIN_KEY = "paliad-sidebar-pinned";
const LEGACY_PIN_KEY = "patholo-sidebar-pinned";
const WIDTH_KEY = "paliad-sidebar-width";
const SIDEBAR_WIDTH_MIN = 180;
const SIDEBAR_WIDTH_MAX = 480;
const SIDEBAR_WIDTH_DEFAULT = 240;
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
// on every scroll event, restored on initSidebar() so a full-page nav
// click doesn't bounce the user back to the top of a long sidebar
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
// starts that tab fresh at the top, which matches user expectation.
const SCROLL_KEY = "paliad.sidebar.scroll";
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
// BottomNav menu slot can call it without duplicating the open/close
// machinery (overlay, body-scroll lock, etc.).
export function toggleMobileSidebar(): void {
const sidebar = document.querySelector<HTMLElement>(".sidebar");
const overlay = document.querySelector<HTMLElement>(".sidebar-overlay");
if (!sidebar) return;
const isOpen = sidebar.classList.contains("mobile-open");
if (isOpen) {
sidebar.classList.remove("mobile-open");
overlay?.classList.remove("visible");
document.body.classList.remove("no-scroll");
} else {
sidebar.classList.add("mobile-open");
overlay?.classList.add("visible");
document.body.classList.add("no-scroll");
}
}
// readStoredWidth returns the user's persisted sidebar width in px, falling
// back to SIDEBAR_WIDTH_DEFAULT when missing, malformed, or outside the
// supported clamp range. Stored values are written by initSidebarResize on
// dragend; the read happens on every page load (initial CSS-var apply).
function readStoredWidth(): number {
const raw = localStorage.getItem(WIDTH_KEY);
if (raw === null) return SIDEBAR_WIDTH_DEFAULT;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < SIDEBAR_WIDTH_MIN || n > SIDEBAR_WIDTH_MAX) {
return SIDEBAR_WIDTH_DEFAULT;
}
return n;
}
function applySidebarWidth(px: number): void {
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
}
// readStoredScroll returns the persisted scrollTop or 0 when missing /
// malformed. Bounds are checked at apply time against the actual
// scrollHeight, so a stale value pointing past the current scroll range
// is harmless (the browser clamps assignments to [0, max]).
function readStoredScroll(): number {
const raw = sessionStorage.getItem(SCROLL_KEY);
if (raw === null) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function applySidebarScroll(nav: HTMLElement, px: number): void {
if (px <= 0) return;
nav.scrollTop = px;
}
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
// first load and removes the stale entry. Drop this fallback once the rename
// grace period is over.
function migrateLegacyPinKey(): void {
const legacy = localStorage.getItem(LEGACY_PIN_KEY);
if (legacy === null) return;
if (localStorage.getItem(PIN_KEY) === null) {
localStorage.setItem(PIN_KEY, legacy);
}
localStorage.removeItem(LEGACY_PIN_KEY);
}
export function initSidebar() {
migrateLegacyPinKey();
// Apply the persisted width before any sidebar paint so the layout is
// stable from frame 1. Safe to call before the .sidebar element exists —
// the CSS var lives on document.documentElement.
applySidebarWidth(readStoredWidth());
initInviteModal();
initGlobalSearch();
initChangelogBadge();
initInboxBadge();
initAdminGroup();
initPaliadinLinks();
initProjectContextChartLink();
initUserViewsGroup();
initThemeToggle();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
initSidebarScrollRestore(sidebar);
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
const overlay = document.querySelector<HTMLElement>(".sidebar-overlay");
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
function isMobile(): boolean {
return window.innerWidth < 1024;
}
// Restore pin state on desktop. We mirror the `sidebar-pinned` class on
// both <body> and <html>: the runtime path historically set it on body,
// but the pre-paint FOUC script in PWAHead.tsx can only reach <html>
// (body doesn't exist yet at that point). Keeping both in sync from
// every site that toggles pin state means the CSS selectors
// `.has-sidebar.sidebar-pinned` (body-rooted) and
// `:root.sidebar-pinned .has-sidebar` (html-rooted) never disagree.
if (localStorage.getItem(PIN_KEY) === "true" && !isMobile()) {
sidebar.classList.add("pinned");
document.body.classList.add("sidebar-pinned");
document.documentElement.classList.add("sidebar-pinned");
} else {
// Pre-paint script may have added it (e.g. last visit was wide enough
// and pinned) but current viewport is mobile — clear so the CSS rule
// doesn't fight the responsive @media block.
document.documentElement.classList.remove("sidebar-pinned");
}
// Desktop: hover expand with 150ms delay
sidebar.addEventListener("mouseenter", () => {
if (isMobile() || sidebar.classList.contains("pinned")) return;
hoverTimer = setTimeout(() => {
sidebar.classList.add("expanded");
}, 150);
});
sidebar.addEventListener("mouseleave", () => {
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
// Mid-resize drags briefly pull the cursor off the sidebar — keep
// the sidebar expanded so the user can finish the drag. The resizing
// class is cleared on dragend; the next mouseleave will collapse.
if (sidebar.classList.contains("resizing")) return;
sidebar.classList.remove("expanded");
});
// Pin toggle
if (pinBtn) {
pinBtn.addEventListener("click", (e) => {
e.preventDefault();
const wasPinned = sidebar.classList.contains("pinned");
if (wasPinned) {
sidebar.classList.remove("pinned");
document.body.classList.remove("sidebar-pinned");
document.documentElement.classList.remove("sidebar-pinned");
localStorage.setItem(PIN_KEY, "false");
} else {
sidebar.classList.add("pinned");
sidebar.classList.remove("expanded");
document.body.classList.add("sidebar-pinned");
document.documentElement.classList.add("sidebar-pinned");
localStorage.setItem(PIN_KEY, "true");
}
});
}
// Mobile: hamburger toggle
function closeMobile() {
sidebar.classList.remove("mobile-open");
overlay?.classList.remove("visible");
document.body.classList.remove("no-scroll");
}
if (hamburger) {
hamburger.addEventListener("click", () => {
toggleMobileSidebar();
});
}
if (overlay) {
overlay.addEventListener("click", closeMobile);
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && sidebar.classList.contains("mobile-open")) {
closeMobile();
}
});
// Close mobile sidebar on nav click
sidebar.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
link.addEventListener("click", () => {
if (isMobile()) closeMobile();
});
});
// Handle resize: clean up states when crossing breakpoint
let wasMobile = isMobile();
window.addEventListener("resize", () => {
const nowMobile = isMobile();
if (wasMobile === nowMobile) return;
wasMobile = nowMobile;
if (nowMobile) {
sidebar.classList.remove("expanded", "pinned");
document.body.classList.remove("sidebar-pinned");
document.documentElement.classList.remove("sidebar-pinned");
} else {
closeMobile();
if (localStorage.getItem(PIN_KEY) === "true") {
sidebar.classList.add("pinned");
document.body.classList.add("sidebar-pinned");
document.documentElement.classList.add("sidebar-pinned");
}
}
});
}
// initSidebarResize wires the right-edge drag handle: mousedown/touchstart
// captures the starting cursor position and current width, mousemove/touchmove
// applies the delta as --sidebar-width (clamped), mouseup/touchend persists
// the final value to localStorage. Double-click on the handle resets to the
// default width. The .resizing class on the sidebar suppresses both the
// width transition (avoid jitter while dragging) and the hover-collapse
// (the cursor leaves the sidebar mid-drag).
function initSidebarResize(sidebar: HTMLElement): void {
const handle = sidebar.querySelector<HTMLElement>(".sidebar-resize-handle");
if (!handle) return;
let dragging = false;
let startX = 0;
let startWidth = 0;
function applyDelta(clientX: number): void {
const delta = clientX - startX;
let next = startWidth + delta;
if (next < SIDEBAR_WIDTH_MIN) next = SIDEBAR_WIDTH_MIN;
if (next > SIDEBAR_WIDTH_MAX) next = SIDEBAR_WIDTH_MAX;
applySidebarWidth(next);
}
function readCurrentWidth(): number {
const raw = getComputedStyle(document.documentElement)
.getPropertyValue("--sidebar-width")
.trim();
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : SIDEBAR_WIDTH_DEFAULT;
}
function endDrag(): void {
if (!dragging) return;
dragging = false;
sidebar.classList.remove("resizing");
document.body.classList.remove("sidebar-resizing");
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", endDrag);
document.removeEventListener("touchmove", onTouchMove);
document.removeEventListener("touchend", endDrag);
document.removeEventListener("touchcancel", endDrag);
localStorage.setItem(WIDTH_KEY, String(readCurrentWidth()));
}
function onMouseMove(e: MouseEvent): void {
applyDelta(e.clientX);
}
function onTouchMove(e: TouchEvent): void {
if (e.touches.length === 0) return;
applyDelta(e.touches[0].clientX);
// preventDefault stops the browser from interpreting the drag as a
// page scroll. touch-action: none on the handle covers most browsers,
// but Safari still needs the explicit call.
if (e.cancelable) e.preventDefault();
}
function startDrag(clientX: number): void {
dragging = true;
startX = clientX;
// Use the rendered sidebar width — robust against any future state
// (pinned vs hover-expanded) where the CSS var might not be the
// single source of truth.
startWidth = sidebar.getBoundingClientRect().width;
sidebar.classList.add("resizing");
document.body.classList.add("sidebar-resizing");
}
handle.addEventListener("mousedown", (e) => {
// Only primary button. Right/middle clicks would otherwise stick the
// sidebar in resizing state until a real mouseup arrives.
if (e.button !== 0) return;
e.preventDefault();
startDrag(e.clientX);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", endDrag);
});
handle.addEventListener("touchstart", (e) => {
if (e.touches.length === 0) return;
startDrag(e.touches[0].clientX);
document.addEventListener("touchmove", onTouchMove, { passive: false });
document.addEventListener("touchend", endDrag);
document.addEventListener("touchcancel", endDrag);
}, { passive: true });
handle.addEventListener("dblclick", (e) => {
e.preventDefault();
applySidebarWidth(SIDEBAR_WIDTH_DEFAULT);
localStorage.setItem(WIDTH_KEY, String(SIDEBAR_WIDTH_DEFAULT));
});
}
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
// sessionStorage so the user's scroll position survives a full-page
// navigation (every sidebar link click is a real reload — see m/paliad#85).
// Restore is synchronous on init so the first paint is already at the
// right offset; the passive scroll listener persists subsequent moves.
// reapplySidebarScroll() exists so callers that mutate sidebar content
// async (initUserViewsGroup appending /api/user-views into the Ansichten
// group) can nudge the scroll back to where it was after the layout shift.
function initSidebarScrollRestore(sidebar: HTMLElement): void {
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
nav.addEventListener("scroll", () => {
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
}, { passive: true });
}
function reapplySidebarScroll(): void {
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
}
// Changelog badge — fetches the count of entries newer than the locally
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
// link. Skipped on the changelog page itself because changelog.ts stamps
// the visit on load and we don't want a flash of badge before that runs.
function initChangelogBadge(): void {
const badge = document.getElementById("sidebar-changelog-badge") as HTMLElement | null;
if (!badge) return;
if (window.location.pathname === "/changelog") return;
const since = getChangelogSeen();
const url = since
? `/api/changelog/unseen-count?since=${encodeURIComponent(since)}`
: "/api/changelog/unseen-count";
fetch(url, { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((data: { count?: number } | null) => {
if (!data || typeof data.count !== "number" || data.count <= 0) return;
badge.textContent = data.count > 9 ? "9+" : String(data.count);
badge.style.display = "";
})
.catch(() => {
// silent: the badge is optional and must never break the page.
});
}
// Inbox badge (t-paliad-138) — count of approval requests where the
// current user is qualified to approve. Polls every 60s while the page
// is open. Silently swallows errors (badge is optional).
function initInboxBadge(): void {
const badge = document.getElementById("sidebar-inbox-badge") as HTMLElement | null;
if (!badge) return;
const refresh = () => {
fetch("/api/inbox/count", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((data: { count?: number } | null) => {
if (!data || typeof data.count !== "number" || data.count <= 0) {
badge.style.display = "none";
return;
}
badge.textContent = data.count > 9 ? "9+" : String(data.count);
badge.style.display = "";
})
.catch(() => {
/* silent */
});
};
refresh();
setInterval(refresh, 60_000);
}
// initThemeToggle wires the sun/moon button at the bottom of the sidebar
// (m/paliad#2). The pre-paint inline script in PWAHead.tsx already set
// the data-theme attribute on <html>; this function only owns the post-
// hydration UI: cycling on click, swapping the icon/label to match the
// current preference, and re-rendering when the OS-level scheme flips
// while the user is on "auto".
const THEME_ICONS: Record<ThemePref, string> = {
auto: '<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="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"/></svg>',
light: '<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="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="M4.93 4.93l1.41 1.41"/><path d="M17.66 17.66l1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="M4.93 19.07l1.41-1.41"/><path d="M17.66 6.34l1.41-1.41"/></svg>',
dark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
};
const THEME_LABEL_KEYS: Record<ThemePref, "theme.toggle.auto" | "theme.toggle.light" | "theme.toggle.dark"> = {
auto: "theme.toggle.auto",
light: "theme.toggle.light",
dark: "theme.toggle.dark",
};
function initThemeToggle(): void {
const btn = document.getElementById("sidebar-theme-toggle") as HTMLButtonElement | null;
const icon = document.getElementById("sidebar-theme-icon") as HTMLElement | null;
const label = document.getElementById("sidebar-theme-label") as HTMLElement | null;
if (!btn || !icon || !label) return;
function render(): void {
const pref = getThemePref();
if (icon) icon.innerHTML = THEME_ICONS[pref];
if (label) {
const key = THEME_LABEL_KEYS[pref];
label.setAttribute("data-i18n", key);
label.textContent = t(key);
}
if (btn) {
btn.setAttribute("aria-label", t(key_for_aria(pref)));
btn.setAttribute("title", t(key_for_aria(pref)));
}
}
function key_for_aria(pref: ThemePref): "theme.toggle.cycle.auto" | "theme.toggle.cycle.light" | "theme.toggle.cycle.dark" {
// The aria-label describes WHAT happens on click, not the current
// state — assistive tech reads "Switch to dark theme" rather than
// just "Auto". Cycle order: auto → light → dark → auto.
if (pref === "auto") return "theme.toggle.cycle.auto"; // → light
if (pref === "light") return "theme.toggle.cycle.light"; // → dark
return "theme.toggle.cycle.dark"; // → auto
}
btn.addEventListener("click", () => {
cycleTheme();
});
// theme.ts notifies on user-cycle AND on OS-level prefers-color-scheme
// changes while the user is on "auto" — a single subscription covers
// both, so the icon/label always tracks the live preference.
onThemeChange(render);
render();
}
// t-paliad-144 Phase A2 — Meine Sichten group hydration. Fetches the
// caller's saved views and renders one nav item per view between the
// group label and the "+ Neue Sicht" trailing entry. Optional count
// badge per view (when show_count=true on the row). The "+ Neue Sicht"
// entry stays in the DOM unconditionally so the group has something
// to show even for first-time users.
interface UserViewLite {
id: string;
slug: string;
name: string;
icon?: string;
show_count: boolean;
}
function initUserViewsGroup(): void {
const items = document.getElementById("sidebar-views-items");
if (!items) return;
// Skip on auth-anon pages (/login, landing) — /api/user-views would 401.
if (!document.body.classList.contains("has-sidebar")) return;
fetch("/api/user-views", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((views: UserViewLite[] | null) => {
if (!views) return;
const currentPath = window.location.pathname;
items.innerHTML = "";
for (const view of views) {
items.appendChild(renderUserViewItem(view, currentPath));
}
// The synchronous restore in initSidebarScrollRestore() happened
// before these views were appended, so a saved scrollTop that
// pointed below the Ansichten group would now sit on the wrong
// row. Re-apply once the layout has stabilised.
reapplySidebarScroll();
// After rendering, kick off count refresh for views that opted in.
for (const view of views) {
if (view.show_count) {
void refreshUserViewCount(view);
}
}
})
.catch(() => {
// Silent — sidebar already shows "+ Neue Sicht" even on failure.
});
}
// fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two
// sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner
// vs /tools/verfahrensablauf), so the SSR navItem helper picks the
// correct active class by pathname alone.
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
const a = document.createElement("a");
a.href = `/views/${encodeURIComponent(view.slug)}`;
const active = currentPath === a.pathname;
a.className = `sidebar-item sidebar-user-view-item${active ? " active" : ""}`;
a.dataset.slug = view.slug;
a.dataset.viewId = view.id;
const iconWrap = document.createElement("span");
iconWrap.className = "sidebar-icon";
iconWrap.innerHTML = userViewIconSvg(view.icon);
a.appendChild(iconWrap);
const label = document.createElement("span");
label.className = "sidebar-label";
label.textContent = view.name;
a.appendChild(label);
if (view.show_count) {
const badge = document.createElement("span");
badge.className = "sidebar-badge sidebar-user-view-badge";
badge.id = `sidebar-user-view-badge-${view.id}`;
badge.style.display = "none";
badge.setAttribute("aria-hidden", "true");
a.appendChild(badge);
}
return a;
}
async function refreshUserViewCount(view: UserViewLite): Promise<void> {
try {
const r = await fetch(`/api/views/${encodeURIComponent(view.slug)}/run`, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!r.ok) return;
const data = (await r.json()) as { rows: unknown[] };
const badge = document.getElementById(`sidebar-user-view-badge-${view.id}`);
if (!badge) return;
if (data.rows.length > 0) {
badge.textContent = String(data.rows.length);
badge.style.display = "";
} else {
badge.style.display = "none";
}
} catch (_e) {
/* noop */
}
}
// userViewIconSvg picks an SVG from a small fixed registry. Falls back
// to the folder icon for unknown / missing keys. Inline SVGs are used
// elsewhere in the sidebar (Sidebar.tsx); we duplicate a minimal subset
// here rather than re-exporting because client TS doesn't import from
// JSX-emitting modules.
function userViewIconSvg(icon?: string): string {
switch (icon) {
case "clock":
return '<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>';
case "calendar":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
case "bell":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
case "users":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
case "building":
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/></svg>';
case "folder":
default:
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
}
}
// PALIADIN_OWNER_EMAIL must match services.PaliadinOwnerEmail (Go side).
// PoC scope — see docs/design-paliadin-2026-05-07.md §0.5.
const PALIADIN_OWNER_EMAIL = "matthias.siebels@hoganlovells.com";
// initPaliadinLinks reveals the Paliadin sidebar entries (under Übersicht
// + Admin) when /api/me confirms the caller is the Paliadin owner. Same
// fail-closed display:none pattern as initAdminGroup. Non-owners never
// see the entries; the routes themselves return 404 if they navigate
// to /paliadin or /admin/paliadin manually anyway.
function initPaliadinLinks(): void {
const top = document.getElementById("sidebar-paliadin-link") as HTMLElement | null;
const admin = document.getElementById("sidebar-admin-paliadin-link") as HTMLElement | null;
if (!top && !admin) return;
fetch("/api/me", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((me: { email?: string } | null) => {
if (me && me.email && me.email.toLowerCase() === PALIADIN_OWNER_EMAIL) {
if (top) top.style.display = "";
if (admin) admin.style.display = "";
}
})
.catch(() => {
// silent: failing closed is the safe default.
});
}
// initProjectContextChartLink (t-paliad-177 Slice 3) reveals an "Als Chart
// anzeigen" entry in the sidebar when the user is browsing a project
// detail page. Hidden everywhere else, hidden on the chart page itself
// (the chart is the destination, not the source).
//
// Self-contained on URL parsing — no per-page handshake needed. Pages
// don't have to know about the sidebar slot; this function walks the
// pathname and renders the link if it matches.
//
// Layout intent: chip sits directly under the "Übersicht" group so it's
// visible on every project sub-tab (Verlauf / Team / Parteien / …).
function initProjectContextChartLink(): void {
const link = document.getElementById("sidebar-project-chart-link") as HTMLAnchorElement | null;
if (!link) return;
const match = /^\/projects\/([0-9a-fA-F-]{36})(\/.*)?$/.exec(window.location.pathname);
if (!match) return;
const id = match[1];
const rest = match[2] || "";
// Hide on the chart page itself — a reciprocal "Zurück zum Verlauf"
// affordance lives on the chart page header (separate slice).
if (rest === "/chart" || rest === "/chart/") return;
link.href = `/projects/${encodeURIComponent(id)}/chart`;
link.style.display = "";
}
// initAdminGroup reveals the Admin section in the sidebar when the caller's
// /api/me lookup confirms global_role='global_admin'. The markup is in the
// DOM with display:none for everyone — flipping it on after the fetch lands
// keeps non-admin pageloads cheap (no flash, no second render) and avoids a
// privilege flash for admins on cached pages.
function initAdminGroup(): void {
const group = document.getElementById("sidebar-admin-group") as HTMLElement | null;
if (!group) return;
fetch("/api/me", { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((me: { global_role?: string } | null) => {
if (me && me.global_role === "global_admin") {
group.style.display = "";
}
})
.catch(() => {
// silent: not being able to check the permission just means we keep
// the section hidden, which fails closed.
});
}
// Invitation modal — opened from the sidebar "Kolleg:in einladen" button.
// Keeps the whole flow client-side: validates, POSTs to /api/invite, shows
// success or the server's error message in the same modal. Kept inside
// sidebar.ts because the Sidebar component owns the modal markup — every
// page that renders <Sidebar /> picks up the behaviour for free.
function initInviteModal(): void {
const btn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
const modal = document.getElementById("invite-modal") as HTMLElement | null;
const closeBtn = document.getElementById("invite-modal-close") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("invite-modal-cancel") as HTMLButtonElement | null;
const form = document.getElementById("invite-form") as HTMLFormElement | null;
const emailInput = document.getElementById("invite-email") as HTMLInputElement | null;
const messageInput = document.getElementById("invite-message") as HTMLTextAreaElement | null;
const submitBtn = document.getElementById("invite-submit") as HTMLButtonElement | null;
const feedback = document.getElementById("invite-feedback") as HTMLElement | null;
if (!btn || !modal || !form || !emailInput || !submitBtn || !feedback) return;
function open(): void {
clearFeedback();
modal!.style.display = "flex";
setTimeout(() => emailInput!.focus(), 30);
}
function close(): void {
modal!.style.display = "none";
form!.reset();
clearFeedback();
}
function clearFeedback(): void {
feedback!.style.display = "none";
feedback!.textContent = "";
feedback!.classList.remove("form-msg-success", "form-msg-error");
}
function setFeedback(kind: "success" | "error", text: string): void {
feedback!.textContent = text;
feedback!.classList.remove("form-msg-success", "form-msg-error");
feedback!.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
feedback!.style.display = "block";
}
btn.addEventListener("click", (e) => {
e.preventDefault();
open();
});
closeBtn?.addEventListener("click", close);
cancelBtn?.addEventListener("click", close);
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display !== "none") close();
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const email = emailInput.value.trim();
const message = messageInput?.value.trim() ?? "";
if (!email) return;
submitBtn.disabled = true;
clearFeedback();
try {
const res = await fetch("/api/invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, message }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
const remaining = typeof data.remaining_today === "number" ? data.remaining_today : null;
const baseMsg = (document.documentElement.lang === "en")
? `Invitation sent to ${email}.`
: `Einladung gesendet an ${email}.`;
const tail = remaining !== null
? ((document.documentElement.lang === "en")
? ` (${remaining} invitations remaining today.)`
: ` (Noch ${remaining} Einladungen heute m\u00f6glich.)`)
: "";
setFeedback("success", baseMsg + tail);
form.reset();
setTimeout(close, 2500);
} else {
const msg = typeof data.error === "string"
? data.error
: ((document.documentElement.lang === "en")
? "Failed to send invitation."
: "Einladung konnte nicht gesendet werden.");
setFeedback("error", msg);
}
} catch (_err) {
setFeedback("error",
(document.documentElement.lang === "en")
? "Network error — please try again."
: "Netzwerkfehler — bitte erneut versuchen.");
} finally {
submitBtn.disabled = false;
}
});
}