Files
ss-tools/frontend/src/lib/components/layout/Sidebar.svelte
2026-02-26 19:40:00 +03:00

322 lines
9.9 KiB
Svelte

<!-- [DEF:Sidebar:Component] -->
<script>
/**
* @TIER: CRITICAL
* @PURPOSE: Persistent left sidebar with resource categories navigation
* @LAYER: UI
* @RELATION: BINDS_TO -> sidebarStore
* @SEMANTICS: Navigation
* @INVARIANT: Always shows active category and item
*
* @UX_STATE: Idle -> Sidebar visible with current state
* @UX_STATE: Toggling -> Animation plays for 200ms
* @UX_FEEDBACK: Active item highlighted with different background
* @UX_RECOVERY: Click outside on mobile closes overlay
*
* @TEST_CONTRACT Component_Sidebar ->
* {
* required_props: {},
* optional_props: {},
* invariants: [
* "Highlights active category and sub-item based on current page URL",
* "Toggles sidebar via toggleSidebar store action",
* "Closes mobile overlay on click outside"
* ]
* }
* @TEST_FIXTURE idle_state -> {}
* @TEST_EDGE mobile_open -> shows mobile overlay mask
* @TEST_INVARIANT navigation -> verifies: [idle_state]
*/
import { onMount } from "svelte";
import { page } from "$app/stores";
import {
sidebarStore,
toggleSidebar,
setActiveItem,
closeMobile,
} from "$lib/stores/sidebar.js";
import { t } from "$lib/i18n";
import { browser } from "$app/environment";
import Icon from "$lib/ui/Icon.svelte";
function buildCategories() {
return [
{
id: "dashboards",
label: $t.nav?.dashboards,
icon: "dashboard",
tone: "from-sky-100 to-sky-200 text-sky-700 ring-sky-200",
path: "/dashboards",
subItems: [{ label: $t.nav?.overview, path: "/dashboards" }],
},
{
id: "datasets",
label: $t.nav?.datasets,
icon: "database",
tone: "from-emerald-100 to-emerald-200 text-emerald-700 ring-emerald-200",
path: "/datasets",
subItems: [{ label: $t.nav?.all_datasets, path: "/datasets" }],
},
{
id: "storage",
label: $t.nav?.storage,
icon: "storage",
tone: "from-amber-100 to-amber-200 text-amber-800 ring-amber-200",
path: "/storage",
subItems: [
{ label: $t.nav?.backups, path: "/storage/backups" },
{
label: $t.nav?.repositories,
path: "/storage/repos",
},
],
},
{
id: "reports",
label: $t.nav?.reports,
icon: "reports",
tone: "from-violet-100 to-fuchsia-100 text-violet-700 ring-violet-200",
path: "/reports",
subItems: [{ label: $t.nav?.reports, path: "/reports" }],
},
{
id: "admin",
label: $t.nav?.admin,
icon: "admin",
tone: "from-rose-100 to-rose-200 text-rose-700 ring-rose-200",
path: "/admin",
subItems: [
{ label: $t.nav?.admin_users, path: "/admin/users" },
{ label: $t.nav?.admin_roles, path: "/admin/roles" },
{ label: $t.nav?.settings, path: "/settings" },
],
},
];
}
let categories = buildCategories();
let isExpanded = true;
let activeCategory = "dashboards";
let activeItem = "/dashboards";
let isMobileOpen = false;
let expandedCategories = new Set(["dashboards"]);
// Subscribe to sidebar store
$: if ($sidebarStore) {
isExpanded = $sidebarStore.isExpanded;
activeCategory = $sidebarStore.activeCategory;
activeItem = $sidebarStore.activeItem;
isMobileOpen = $sidebarStore.isMobileOpen;
}
// Reactive categories to update translations
$: categories = buildCategories();
// Update active item when page changes
$: if ($page && $page.url.pathname !== activeItem) {
const matched = categories.find((cat) =>
$page.url.pathname.startsWith(cat.path),
);
if (matched) {
activeCategory = matched.id;
activeItem = $page.url.pathname;
}
}
function handleItemClick(category) {
console.log(`[Sidebar][Action] Clicked category ${category.id}`);
setActiveItem(category.id, category.path);
closeMobile();
if (browser) {
window.location.href = category.path;
}
}
function handleCategoryToggle(categoryId, event) {
event.stopPropagation();
if (!isExpanded) {
console.log(
`[Sidebar][Action] Expand sidebar and category ${categoryId}`,
);
toggleSidebar();
expandedCategories.add(categoryId);
expandedCategories = expandedCategories;
return;
}
console.log(`[Sidebar][Action] Toggle category ${categoryId}`);
if (expandedCategories.has(categoryId)) {
expandedCategories.delete(categoryId);
} else {
expandedCategories.add(categoryId);
}
expandedCategories = expandedCategories;
}
function handleSubItemClick(categoryId, path) {
console.log(`[Sidebar][Action] Clicked sub-item ${path}`);
setActiveItem(categoryId, path);
closeMobile();
if (browser) {
window.location.href = path;
}
}
function handleToggleClick(event) {
event.stopPropagation();
console.log("[Sidebar][Action] Toggle sidebar");
toggleSidebar();
}
function handleOverlayClick() {
console.log("[Sidebar][Action] Close mobile overlay");
closeMobile();
}
// Close mobile overlay on route change
$: if (isMobileOpen && $page) {
closeMobile();
}
</script>
<!-- Mobile overlay (only on mobile) -->
{#if isMobileOpen}
<div
class="fixed inset-0 bg-black/50 z-20 md:hidden"
on:click={handleOverlayClick}
on:keydown={(e) => e.key === "Escape" && handleOverlayClick()}
role="presentation"
></div>
{/if}
<!-- Sidebar -->
<div
class="fixed left-0 top-0 z-30 flex h-screen flex-col border-r border-slate-200 bg-white shadow-sm transition-[width] duration-200 ease-in-out
{isExpanded ? 'w-sidebar' : 'w-sidebar-collapsed'}
{isMobileOpen
? 'translate-x-0 w-sidebar'
: '-translate-x-full md:translate-x-0'}"
>
<!-- Header -->
<div
class="flex items-center border-b border-slate-200 p-4 {isExpanded
? 'justify-between'
: 'justify-center'}"
>
{#if isExpanded}
<span class="font-semibold text-gray-800 flex items-center gap-2">
<span
class="inline-flex h-6 w-6 items-center justify-center rounded-md bg-gradient-to-br from-slate-100 to-slate-200 text-slate-700 ring-1 ring-slate-200"
>
<Icon name="layers" size={14} />
</span>
{$t.nav?.menu}
</span>
{:else}
<span class="text-xs text-gray-500">M</span>
{/if}
</div>
<!-- Navigation items -->
<nav class="flex-1 overflow-y-auto py-2">
{#each categories as category}
<div>
<!-- Category Header -->
<div
class="flex cursor-pointer items-center justify-between px-4 py-3 transition-colors hover:bg-slate-100
{activeCategory === category.id
? 'bg-primary-light text-primary md:border-r-2 md:border-primary'
: ''}"
on:click={(e) => handleCategoryToggle(category.id, e)}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
handleCategoryToggle(category.id, e)}
role="button"
tabindex="0"
aria-label={category.label}
aria-expanded={expandedCategories.has(category.id)}
>
<div class="flex items-center">
<span
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br ring-1 transition-all {category.tone}"
>
<Icon name={category.icon} size={16} strokeWidth={2} />
</span>
{#if isExpanded}
<span class="ml-3 text-sm font-medium truncate"
>{category.label}</span
>
{/if}
</div>
{#if isExpanded}
<Icon
name="chevronDown"
size={16}
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
category.id,
)
? 'rotate-180'
: ''}"
/>
{/if}
</div>
<!-- Sub Items (only when expanded) -->
{#if isExpanded && expandedCategories.has(category.id)}
<div class="bg-gray-50 overflow-hidden transition-all duration-200">
{#each category.subItems as subItem}
<div
class="flex items-center px-4 py-2 pl-12 cursor-pointer transition-colors text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900
{activeItem === subItem.path
? 'bg-primary-light text-primary'
: ''}"
on:click={() => handleSubItemClick(category.id, subItem.path)}
on:keydown={(e) =>
(e.key === "Enter" || e.key === " ") &&
handleSubItemClick(category.id, subItem.path)}
role="button"
tabindex="0"
>
{subItem.label}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</nav>
<!-- Footer with Collapse button -->
{#if isExpanded}
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
on:click={handleToggleClick}
>
<span
class="mr-2 inline-flex h-6 w-6 items-center justify-center rounded-md bg-slate-100 text-slate-600"
>
<Icon name="chevronLeft" size={14} />
</span>
{$t.nav?.collapse}
</button>
</div>
{:else}
<div class="border-t border-gray-200 p-4">
<button
class="flex items-center justify-center w-full px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
on:click={handleToggleClick}
aria-label={$t.nav?.expand_sidebar}
>
<Icon name="chevronRight" size={16} />
<span class="ml-2">{$t.nav?.expand}</span>
</button>
</div>
{/if}
</div>
<!-- [/DEF:Sidebar:Component] -->