From 132992ba2a8e85f98c9f3d947a1f4ee46fddc080 Mon Sep 17 00:00:00 2001 From: m Date: Sun, 26 Apr 2026 15:27:24 +0200 Subject: [PATCH] feat(sidebar): resizable width with drag handle + persistence (t-paliad-047) Adds a 6px col-resize strip on the right edge of the desktop sidebar. Drag updates --sidebar-width on document.documentElement (clamped 180-480px). Mouse + touch handlers; double-click resets to the 240px default. Width persists via localStorage["paliad-sidebar-width"], read on every page load before first paint so layout is stable from frame 1. The handle is opacity-faded on the icon-rail (collapsed) state and hidden entirely under the mobile breakpoint, since the mobile sidebar is an overlay drawer that always uses the fixed --sidebar-expanded width. Pin/unpin behaviour is preserved: pinned state keeps the user's chosen width; unpinning drops to the icon rail; hover-expand restores the chosen width. The hover-collapse mouseleave handler ignores transitions during an active drag so the sidebar doesn't snap shut mid-resize. --- frontend/src/client/i18n.ts | 4 + frontend/src/client/sidebar.ts | 124 ++++++++++++++++++++++++++++ frontend/src/components/Sidebar.tsx | 7 ++ frontend/src/styles/global.css | 54 +++++++++++- 4 files changed, 186 insertions(+), 3 deletions(-) diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 824ba61..2395242 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -792,6 +792,8 @@ const translations: Record> = { "palette.footer.open": "Öffnen", "palette.footer.close": "Schließen", + "sidebar.resize.title": "Breite anpassen — ziehen, Doppelklick setzt zurück", + // Settings page (t-paliad-022) "einstellungen.title": "Einstellungen \u2014 Paliad", "einstellungen.heading": "Einstellungen", @@ -1953,6 +1955,8 @@ const translations: Record> = { "palette.footer.open": "Open", "palette.footer.close": "Close", + "sidebar.resize.title": "Resize \u2014 drag, double-click to reset", + // Settings page (t-paliad-022) "einstellungen.title": "Settings \u2014 Paliad", "einstellungen.heading": "Settings", diff --git a/frontend/src/client/sidebar.ts b/frontend/src/client/sidebar.ts index fcb2e87..2b4d915 100644 --- a/frontend/src/client/sidebar.ts +++ b/frontend/src/client/sidebar.ts @@ -5,6 +5,10 @@ import { initGlobalSearch } from "./search"; import { getChangelogSeen } from "./changelog-seen"; 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; // toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the // BottomNav menu slot can call it without duplicating the open/close @@ -25,6 +29,24 @@ export function toggleMobileSidebar(): void { } } +// 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`); +} + // 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. @@ -39,11 +61,16 @@ function migrateLegacyPinKey(): void { 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(); const sidebar = document.querySelector(".sidebar"); if (!sidebar) return; + initSidebarResize(sidebar); const pinBtn = sidebar.querySelector(".sidebar-pin"); const hamburger = document.querySelector(".sidebar-hamburger"); @@ -74,6 +101,10 @@ export function initSidebar() { 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"); }); @@ -145,6 +176,99 @@ export function initSidebar() { }); } +// 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(".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)); + }); +} + // 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 diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8d71b22..72a17e2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -161,6 +161,13 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st Abmelden + + {/* 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. */} +