Per m's Q11 divergence in the design (no 2-week dual-ship), this slice flips /tools/fristenrechner and /tools/verfahrensablauf to permanent 301 redirects to /tools/procedures and deletes the legacy frontend pages. Bookmarks resolve via Location preservation of query params; no ?legacy=1 escape, no in-product affordance pointed back at the retired URLs after the merge. Server: - handleFristenrechnerPage + handleVerfahrensablaufPage now 301 to /tools/procedures, carrying any query string through unchanged. - pillDrillURL in deadline_search_service.go retargets to /tools/procedures so freshly indexed search pills land on the new page directly (cached snapshots still work via the 301). Frontend: - Deleted src/fristenrechner.tsx, src/verfahrensablauf.tsx, src/client/fristenrechner.ts. - src/client/verfahrensablauf.ts loses its DOMContentLoaded auto-boot and the now-unused initI18n / initSidebar imports; procedures.ts is the sole caller of initVerfahrensablauf(). - frontend/build.ts drops the legacy entrypoints and renderXxx HTML outputs. - Sidebar.tsx, Header.tsx, index.tsx, paliadin-context.ts repointed to /tools/procedures. - Unused nav.fristenrechner / nav.verfahrensablauf / tools.verfahrensablauf.* i18n keys removed. Tests: - verfahrensablauf_test.go rewritten to assert both legacy URLs return 301 with the correct Location (query string preserved).
306 lines
25 KiB
TypeScript
306 lines
25 KiB
TypeScript
import { h, Fragment } from "../jsx";
|
|
import { FIRM } from "../branding";
|
|
|
|
const ICON_HOME = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
|
|
const ICON_CALC = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><line x1="8" y1="14" x2="8" y2="14.01"/><line x1="12" y1="14" x2="12" y2="14.01"/><line x1="16" y1="14" x2="16" y2="14.01"/><line x1="8" y1="18" x2="16" y2="18"/></svg>';
|
|
const ICON_CLOCK = '<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>';
|
|
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
|
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
|
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
|
|
// Open-book icon for the /tools/verfahrensablauf "Verfahrensablauf"
|
|
// nav entry (t-paliad-168 → t-paliad-179 Slice 1 split). Distinct from
|
|
// ICON_BOOK (Glossar, closed) so the two affordances read as different
|
|
// at a glance.
|
|
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
|
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
|
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
|
|
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
|
|
// affordance reads as "a draft document" at a glance.
|
|
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
|
|
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
|
const ICON_GLOBE = '<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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
|
const ICON_BUILDING = '<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"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
|
|
const ICON_LOGOUT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
|
|
const ICON_PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v6l-2 4h10l-2-4V4"/><line x1="12" y1="16" x2="12" y2="21"/><line x1="8" y1="4" x2="16" y2="4"/></svg>';
|
|
const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
|
|
const ICON_FOLDER = '<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>';
|
|
const ICON_CALENDAR = '<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>';
|
|
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
|
|
const ICON_GEAR = '<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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
|
|
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
|
|
const ICON_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
|
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
|
|
const ICON_USERS = '<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>';
|
|
const ICON_SHIELD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
|
const ICON_AUDIT_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
|
|
// Newspaper icon for the /changelog "Neuigkeiten" entry. Sparkle is now
|
|
// reserved for the Paliadin AI surface so the two affordances don't
|
|
// share a glyph (m's 2026-05-08 21:11 dogfood).
|
|
const ICON_NEWSPAPER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8z"/></svg>';
|
|
// Bell icon for the /inbox entry (t-paliad-138 4-eye approval inbox).
|
|
const ICON_BELL = '<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>';
|
|
// Theme-toggle icons. The button cycles auto → light → dark → auto, and
|
|
// the icon swaps to reflect the *current* preference (auto/light/dark)
|
|
// — not the eventual click target. SSR renders the auto variant; the
|
|
// runtime client (sidebar.ts) re-renders the matching icon on hydration
|
|
// after reading localStorage.
|
|
const ICON_THEME_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>';
|
|
const ICON_THEME_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>';
|
|
const ICON_THEME_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>';
|
|
|
|
interface SidebarProps {
|
|
currentPath: string;
|
|
// authenticated defaults to true; pass false on anon-facing pages (the
|
|
// marketing landing) so we don't render auth-only affordances that would
|
|
// fire 401s. Currently gates only the changelog badge link — the badge
|
|
// client (sidebar.ts initChangelogBadge) early-returns when the link is
|
|
// absent, so there is no client change needed.
|
|
authenticated?: boolean;
|
|
}
|
|
|
|
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string, badgeID?: string): string {
|
|
// "Active" is true for the item whose href is a prefix of currentPath.
|
|
// That way sub-routes like /projekte/{id}/events keep the /projekte entry lit.
|
|
// /akten and /akten/* are kept as legacy aliases and also highlight /projekte
|
|
const active =
|
|
href === currentPath ||
|
|
(href !== "/" && currentPath.startsWith(href + "/"));
|
|
return (
|
|
<a href={href} className={`sidebar-item${active ? " active" : ""}`}>
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: icon }} />
|
|
<span className="sidebar-label" data-i18n={i18nKey}>{label}</span>
|
|
{badgeID ? <span className="sidebar-badge" id={badgeID} style="display:none" aria-hidden="true" /> : ""}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function navItemDisabled(icon: string, i18nKey: string, label: string, tooltipI18n: string, tooltipText: string): string {
|
|
return (
|
|
<span className="sidebar-item sidebar-item-disabled" title={tooltipText} data-i18n-title={tooltipI18n}>
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: icon }} />
|
|
<span className="sidebar-label" data-i18n={i18nKey}>{label}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function group(i18nKey: string, label: string, children: string): string {
|
|
return (
|
|
<div className="sidebar-group">
|
|
<div className="sidebar-group-label" data-i18n={i18nKey}>{label}</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Sidebar({ currentPath, authenticated = true }: SidebarProps): string {
|
|
return (
|
|
<Fragment>
|
|
<aside className="sidebar">
|
|
<div className="sidebar-header">
|
|
<a href="/" className="sidebar-logo">
|
|
<span className="sidebar-icon"><span className="logo-mark">p</span></span>
|
|
<span className="sidebar-label logo-text">Paliad</span>
|
|
</a>
|
|
<button className="sidebar-pin" type="button" aria-label="Pin sidebar">
|
|
<span dangerouslySetInnerHTML={{ __html: ICON_PIN }} />
|
|
</button>
|
|
</div>
|
|
|
|
<nav className="sidebar-nav">
|
|
<div className="sidebar-search" id="sidebar-search">
|
|
<span className="sidebar-icon sidebar-search-icon"
|
|
dangerouslySetInnerHTML={{ __html: ICON_SEARCH }} />
|
|
<input type="search" id="global-search-input"
|
|
className="sidebar-search-input"
|
|
autocomplete="off" spellcheck={false}
|
|
placeholder="Suchen..."
|
|
data-i18n-placeholder="search.placeholder"
|
|
aria-label="Suche" />
|
|
<kbd className="sidebar-search-kbd" aria-hidden="true" title="/ oder Ctrl/Cmd+K">/</kbd>
|
|
</div>
|
|
<div className="search-overlay" id="global-search-overlay"
|
|
role="listbox" aria-label="Suchergebnisse"
|
|
style="display:none" />
|
|
|
|
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
|
|
|
|
{/* Paliadin top-level entry (t-paliad-162) \u2014 owner-only, hidden
|
|
by default. sidebar.ts reveals it after /api/me confirms the
|
|
caller is the Paliadin owner (t-paliad-146 PoC scope). Same
|
|
fail-closed pattern as the admin group below. Sits directly
|
|
under Home per m's design call so owners hit their assistant
|
|
with one click from anywhere. */}
|
|
<a href="/paliadin"
|
|
className={`sidebar-item sidebar-paliadin${currentPath === "/paliadin" ? " active" : ""}`}
|
|
id="sidebar-paliadin-link" style="display:none">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
|
<span className="sidebar-label" data-i18n="nav.paliadin">Paliadin</span>
|
|
</a>
|
|
|
|
{group("nav.group.uebersicht", "\u00DCbersicht",
|
|
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
|
|
navItem("/projects", ICON_FOLDER, "nav.projekte", "Projekte", currentPath) +
|
|
navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") +
|
|
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
|
)}
|
|
|
|
{/* t-paliad-177 \u2014 contextual chart link, revealed by sidebar.ts
|
|
when the user is on a /projects/{id}/* page (but NOT on the
|
|
chart itself). The href is filled in client-side from the
|
|
URL path so the same Sidebar TSX serves every page. */}
|
|
<a href="#"
|
|
className="sidebar-item sidebar-context-chart"
|
|
id="sidebar-project-chart-link"
|
|
style="display:none">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_GAUGE }} />
|
|
<span className="sidebar-label" data-i18n="nav.context.project_chart">Als Chart anzeigen</span>
|
|
</a>
|
|
|
|
{/* Ansichten \u2014 single consolidated group (m's 2026-05-08 20:32
|
|
dogfood: "all views under one — not Ansichten and meine Ansichten").
|
|
Holds the built-in Fristen + Termine, the user-defined views
|
|
hydrated by client/sidebar.ts from /api/user-views, and the
|
|
"+ Neue Sicht" entry. The previous "Meine Sichten" split is gone. */}
|
|
<div className="sidebar-group sidebar-views-group" id="sidebar-views-group">
|
|
<div className="sidebar-group-label" data-i18n="nav.group.ansichten">Ansichten</div>
|
|
{navItem("/events?type=deadline", ICON_CLOCK, "nav.fristen", "Fristen", currentPath)}
|
|
{navItem("/events?type=appointment", ICON_CALENDAR, "nav.termine", "Termine", currentPath)}
|
|
<div className="sidebar-views-items" id="sidebar-views-items" />
|
|
<a href="/views/new" className="sidebar-item sidebar-views-new">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_FOLDER }} />
|
|
<span className="sidebar-label" data-i18n="nav.user_views.new">Neue Sicht</span>
|
|
</a>
|
|
</div>
|
|
|
|
{/* t-paliad-162 — single Werkzeuge group consolidating the prior
|
|
Werkzeuge / Wissen / Ressourcen splits. Order follows m's
|
|
brief: calculators first, then reference (Checklisten /
|
|
Gerichte / Glossar), then content (Links / Downloads). */}
|
|
{group("nav.group.werkzeuge", "Werkzeuge",
|
|
navItem("/tools/procedures", ICON_BOOK_OPEN, "nav.procedures", "Verfahren & Fristen", currentPath) +
|
|
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
|
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
|
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
|
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
|
navItem("/courts", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath) +
|
|
navItem("/glossary", ICON_BOOK, "nav.glossar", "Glossar", currentPath) +
|
|
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath) +
|
|
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath),
|
|
)}
|
|
|
|
{group("nav.group.einstellungen", "Einstellungen",
|
|
navItem("/settings", ICON_GEAR, "nav.einstellungen", "Einstellungen", currentPath),
|
|
)}
|
|
|
|
{/* Admin section: hidden by default. sidebar.ts reveals it after a
|
|
successful /api/me lookup confirms role='admin'. Keeping the
|
|
markup in the DOM (vs. fetched HTML) means there's nothing to
|
|
flash in/out for admins on subsequent navigations once the
|
|
role is known — only the first visit waits for /api/me. */}
|
|
<div className="sidebar-group sidebar-admin-group" id="sidebar-admin-group" style="display:none">
|
|
<div className="sidebar-group-label" data-i18n="nav.group.admin">Admin</div>
|
|
{navItem("/admin", ICON_SHIELD, "nav.admin.bereich", "Admin-Bereich", currentPath)}
|
|
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
|
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
|
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
|
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
|
|
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
|
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
|
|
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
|
|
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
|
|
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}
|
|
style="display:none">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
|
|
<span className="sidebar-label" data-i18n="nav.admin.paliadin">Paliadin Monitor</span>
|
|
</a>
|
|
</div>
|
|
</nav>
|
|
|
|
<div className="sidebar-spacer" />
|
|
|
|
<div className="sidebar-bottom">
|
|
{authenticated ? (
|
|
<a href="/changelog" className={`sidebar-item sidebar-changelog${currentPath === "/changelog" ? " active" : ""}`} id="sidebar-changelog-link">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_NEWSPAPER }} />
|
|
<span className="sidebar-label" data-i18n="nav.neuigkeiten">Neuigkeiten</span>
|
|
<span className="sidebar-badge" id="sidebar-changelog-badge" style="display:none" aria-hidden="true" />
|
|
</a>
|
|
) : ""}
|
|
<button type="button" className="sidebar-item sidebar-invite-btn" id="sidebar-invite-btn">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
|
|
<span className="sidebar-label" data-i18n="invite.button">Kolleg:in einladen</span>
|
|
</button>
|
|
<div className="sidebar-item sidebar-lang-item">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_GLOBE }} />
|
|
<span className="sidebar-label">
|
|
<span className="sidebar-lang">
|
|
<button className="lang-btn lang-active" data-lang-toggle="de" type="button">DE</button>
|
|
<span className="lang-sep">/</span>
|
|
<button className="lang-btn" data-lang-toggle="en" type="button">EN</button>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
{/* Theme toggle (m/paliad#2). SSR renders the auto/system variant;
|
|
client/sidebar.ts swaps the icon + label to match the persisted
|
|
preference on hydration, and re-renders on every cycle. The
|
|
data-theme-toggle attribute is the click handle so the wiring
|
|
survives any future markup churn. */}
|
|
<button type="button" className="sidebar-item sidebar-theme-toggle"
|
|
id="sidebar-theme-toggle" data-theme-toggle="cycle"
|
|
aria-label="Theme">
|
|
<span className="sidebar-icon" id="sidebar-theme-icon"
|
|
dangerouslySetInnerHTML={{ __html: ICON_THEME_AUTO }} />
|
|
<span className="sidebar-label" id="sidebar-theme-label" data-i18n="theme.toggle.auto">Auto</span>
|
|
</button>
|
|
<a href="/logout" className="sidebar-item sidebar-logout">
|
|
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_LOGOUT }} />
|
|
<span className="sidebar-label" data-i18n="nav.logout">Abmelden</span>
|
|
</a>
|
|
</div>
|
|
|
|
{/* Drag-strip on the right edge — sidebar.ts wires mouse/touch
|
|
handlers and persists the chosen width to localStorage.
|
|
CSS hides it when the sidebar is collapsed/icon-rail or on mobile. */}
|
|
<div className="sidebar-resize-handle" role="separator" aria-orientation="vertical"
|
|
aria-label="Resize sidebar"
|
|
title="Breite anpassen" data-i18n-title="sidebar.resize.title" />
|
|
</aside>
|
|
|
|
<button className="sidebar-hamburger" type="button" aria-label="Menu">
|
|
<span dangerouslySetInnerHTML={{ __html: ICON_MENU }} />
|
|
</button>
|
|
<div className="sidebar-overlay" />
|
|
|
|
{/* Invitation modal — lives alongside the sidebar so every page can
|
|
open it. Hidden by default; sidebar.ts toggles display. */}
|
|
<div className="modal-overlay" id="invite-modal" style="display:none">
|
|
<div className="modal-card">
|
|
<div className="modal-header">
|
|
<h2 data-i18n="invite.modal.title">Kolleg:in zu Paliad einladen</h2>
|
|
<button className="modal-close" id="invite-modal-close" type="button" aria-label="Close">×</button>
|
|
</div>
|
|
<p data-i18n="invite.modal.body" className="invite-modal-body">
|
|
{`Senden Sie eine Einladung an eine ${FIRM}-E-Mail-Adresse. Die Empfänger:in erhält einen Registrierungslink.`}
|
|
</p>
|
|
<form id="invite-form" className="entity-form" autocomplete="off">
|
|
<div className="form-field">
|
|
<label htmlFor="invite-email" data-i18n="invite.modal.email">E-Mail-Adresse</label>
|
|
<input type="email" id="invite-email" name="email" required placeholder="kolleg@hlc.com" />
|
|
</div>
|
|
<div className="form-field">
|
|
<label htmlFor="invite-message" data-i18n="invite.modal.message">Persönliche Nachricht (optional)</label>
|
|
<textarea id="invite-message" name="message" rows={4} data-i18n-placeholder="invite.modal.message.placeholder"
|
|
placeholder="Hi, ich nutze Paliad für die Aktenverwaltung — schau es dir mal an." />
|
|
</div>
|
|
<div id="invite-feedback" className="form-msg" style="display:none" />
|
|
<div className="form-actions">
|
|
<button type="button" className="btn-cancel" id="invite-modal-cancel" data-i18n="invite.modal.cancel">Abbrechen</button>
|
|
<button type="submit" className="btn-primary" id="invite-submit" data-i18n="invite.modal.send">Einladung senden</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</Fragment>
|
|
);
|
|
}
|