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.
754 lines
31 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
}
|