Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Large diffs are not rendered by default.

This file was deleted.

4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 105 additions & 21 deletions web/dashboard/src/lib/components/organisms/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
import { router } from "$lib/stores/router.svelte.js";
import { sidebar } from "$lib/stores/ui.svelte.js";
import { auth } from "$lib/stores/auth.svelte.js";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
sidebarWidthFromPointer,
} from "$lib/stores/sidebar-sizing.js";
import { gomodelPath } from "$lib/api/paths.js";
import { NAV_ITEMS } from "./navigation.js";
import { LockKeyhole } from "lucide";
Expand All @@ -14,9 +19,69 @@
const navItems = $derived(
NAV_ITEMS.filter((item) => !item.visible || item.visible()),
);

let resizePointerID = $state(null);
let resizeStartX = 0;
let resizeStartWidth = 0;
let dragged = false;

function startResize(event) {
if (event.button !== 0) return;
event.preventDefault();
resizePointerID = event.pointerId;
resizeStartX = event.clientX;
resizeStartWidth = sidebar.width;
dragged = false;
event.currentTarget.setPointerCapture(event.pointerId);
document.body.classList.add("sidebar-resizing");
}

function dragResize(event) {
if (event.pointerId !== resizePointerID) return;
if (!dragged && Math.abs(event.clientX - resizeStartX) <= 4) return;
dragged = true;
sidebar.setWidth(
sidebarWidthFromPointer(resizeStartWidth, resizeStartX, event.clientX),
);
}

function finishResize(event) {
if (resizePointerID === null ||
(event.pointerId !== undefined && event.pointerId !== resizePointerID)) return;
resizePointerID = null;
document.body.classList.remove("sidebar-resizing");
sidebar.setWidth(sidebar.width, true);
}

function toggleSidebar() {
if (dragged) {
dragged = false;
return;
}
sidebar.toggle();
}

function resizeWithKeyboard(event) {
let width;
if (event.key === "ArrowLeft") width = sidebar.width - 12;
else if (event.key === "ArrowRight") width = sidebar.width + 12;
else if (event.key === "Home") width = MIN_SIDEBAR_WIDTH;
else if (event.key === "End") width = MAX_SIDEBAR_WIDTH;
else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
sidebar.toggle();
return;
} else return;
event.preventDefault();
sidebar.setWidth(width, true);
}
</script>

<aside class="sidebar" class:sidebar-collapsed={sidebar.collapsed}>
<aside
class="sidebar"
class:sidebar-collapsed={sidebar.collapsed}
class:sidebar-resizing={resizePointerID !== null}
>
<div class="sidebar-header">
<div class="sidebar-logo">
<GoModelLogo />
Expand All @@ -37,7 +102,7 @@
}}
>
<Icon icon={item.icon} class="nav-icon" />
<span>{item.label}</span>
<span class="nav-label">{item.label}</span>
</a>
{/each}
</nav>
Expand All @@ -58,15 +123,26 @@
{/if}
</div>
</aside>
<button
type="button"
<!-- A focusable separator is the ARIA window-splitter pattern. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
<div
class="sidebar-toggle"
class:collapsed={sidebar.collapsed}
onclick={() => sidebar.toggle()}
title={sidebar.collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-label={sidebar.collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!sidebar.collapsed}
></button>
role="separator"
tabindex="0"
title="Drag to resize; click to collapse or expand"
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemin={MIN_SIDEBAR_WIDTH}
aria-valuemax={MAX_SIDEBAR_WIDTH}
aria-valuenow={sidebar.width}
onpointerdown={startResize}
onpointermove={dragResize}
onpointerup={finishResize}
onpointercancel={finishResize}
onlostpointercapture={finishResize}
onkeydown={resizeWithKeyboard}
onclick={toggleSidebar}
></div>

<style>
.sidebar {
Expand All @@ -80,13 +156,18 @@
top: 0;
max-height: 100vh;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
z-index: 10;
transition:
flex-basis 0.2s,
width 0.2s;
}

.sidebar.sidebar-resizing {
transition: none;
}

.sidebar-header {
padding: 20px;
border-bottom: 1px solid var(--border);
Expand Down Expand Up @@ -144,6 +225,14 @@
color: #fff;
}

.nav-label {
flex: 1;
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}

.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border);
Expand Down Expand Up @@ -195,7 +284,7 @@
padding: 0;
background: transparent;
border: none;
cursor: w-resize;
cursor: ew-resize;
z-index: 11;
transition: background 0.15s;
}
Expand All @@ -204,19 +293,14 @@
background: color-mix(in srgb, var(--accent) 15%, transparent);
}

.sidebar-toggle.collapsed {
cursor: e-resize;
}

.sidebar-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--accent) 36%, transparent);
outline-offset: 2px;
}

/* Collapsed sidebar (desktop) */
.sidebar.sidebar-collapsed {
flex-basis: 60px;
width: 60px;
:global(body.sidebar-resizing) {
cursor: ew-resize;
user-select: none;
}

.sidebar.sidebar-collapsed .sidebar-header {
Expand All @@ -233,7 +317,7 @@
padding: 10px;
}

.sidebar.sidebar-collapsed .sidebar-nav .nav-item :global(span) {
.sidebar.sidebar-collapsed .sidebar-nav .nav-label {
display: none;
}

Expand Down Expand Up @@ -265,7 +349,7 @@
padding: 10px;
}

.sidebar-nav .nav-item :global(span) {
.sidebar-nav .nav-item .nav-label {
display: none;
}

Expand Down
12 changes: 12 additions & 0 deletions web/dashboard/src/lib/stores/sidebar-sizing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const MIN_SIDEBAR_WIDTH = 60;
export const MAX_SIDEBAR_WIDTH = 240;

export function clampSidebarWidth(value) {
const width = Number(value);
if (!Number.isFinite(width)) return MAX_SIDEBAR_WIDTH;
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, Math.round(width)));
}

export function sidebarWidthFromPointer(startWidth, startX, clientX) {
return clampSidebarWidth(Number(startWidth) + Number(clientX) - Number(startX));
}
35 changes: 31 additions & 4 deletions web/dashboard/src/lib/stores/ui.svelte.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Cross-cutting UI state: theme, sidebar collapse, and the count of open
// Cross-cutting UI state: theme, sidebar sizing, and the count of open
// overlay dialogs (drives the body-level dashboard-modal-open class).

import { readStored, writeStored } from "$lib/utils/storage.js";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
clampSidebarWidth,
} from "./sidebar-sizing.js";

class ThemeStore {
theme = $state("system");
Expand Down Expand Up @@ -47,14 +52,36 @@ class ThemeStore {
export const themeStore = new ThemeStore();

class SidebarStore {
collapsed = $state(false);
width = $state(MAX_SIDEBAR_WIDTH);

get collapsed() {
return this.width === MIN_SIDEBAR_WIDTH;
}

init() {
this.collapsed = readStored("gomodel_sidebar_collapsed") === "true";
const storedWidth = readStored("gomodel_sidebar_width");
const legacyCollapsed = readStored("gomodel_sidebar_collapsed") === "true";
this.setWidth(
storedWidth == null
? (legacyCollapsed ? MIN_SIDEBAR_WIDTH : MAX_SIDEBAR_WIDTH)
: storedWidth,
);
}

toggle() {
this.collapsed = !this.collapsed;
this.setWidth(
this.collapsed ? MAX_SIDEBAR_WIDTH : MIN_SIDEBAR_WIDTH,
true,
);
}

setWidth(value, persist = false) {
this.width = clampSidebarWidth(value);
if (typeof document !== "undefined") {
document.documentElement.style.setProperty("--sidebar-width", `${this.width}px`);
}
if (!persist) return;
writeStored("gomodel_sidebar_width", this.width);
writeStored("gomodel_sidebar_collapsed", this.collapsed);
}
}
Expand Down
Loading