322 lines
9.9 KiB
Svelte
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] -->
|