361 lines
11 KiB
Svelte
361 lines
11 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
|
|
*/
|
|
|
|
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";
|
|
|
|
// Sidebar categories with sub-items matching Superset-style navigation
|
|
let categories = [
|
|
{
|
|
id: "dashboards",
|
|
label: $t.nav?.dashboards || "DASHBOARDS",
|
|
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
|
path: "/dashboards",
|
|
subItems: [
|
|
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
|
],
|
|
},
|
|
{
|
|
id: "datasets",
|
|
label: $t.nav?.datasets || "DATASETS",
|
|
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
|
path: "/datasets",
|
|
subItems: [
|
|
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
|
],
|
|
},
|
|
{
|
|
id: "storage",
|
|
label: $t.nav?.storage || "STORAGE",
|
|
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
|
path: "/storage",
|
|
subItems: [
|
|
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
|
{
|
|
label: $t.nav?.repositories || "Repositories",
|
|
path: "/storage/repos",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "admin",
|
|
label: $t.nav?.admin || "ADMIN",
|
|
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
|
path: "/admin",
|
|
subItems: [
|
|
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
|
{ label: $t.nav?.admin_roles || "Roles", path: "/admin/roles" },
|
|
{ label: $t.nav?.settings || "Settings", path: "/settings" },
|
|
],
|
|
},
|
|
];
|
|
|
|
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 = [
|
|
{
|
|
id: "dashboards",
|
|
label: $t.nav?.dashboards || "DASHBOARDS",
|
|
icon: "M3 3h18v18H3V3zm16 16V5H5v14h14z",
|
|
path: "/dashboards",
|
|
subItems: [
|
|
{ label: $t.nav?.overview || "Overview", path: "/dashboards" },
|
|
],
|
|
},
|
|
{
|
|
id: "datasets",
|
|
label: $t.nav?.datasets || "DATASETS",
|
|
icon: "M3 3h18v18H3V3zm2 2v14h14V5H5zm2 2h10v2H7V7zm0 4h10v2H7v-2zm0 4h6v2H7v-2z",
|
|
path: "/datasets",
|
|
subItems: [
|
|
{ label: $t.nav?.all_datasets || "All Datasets", path: "/datasets" },
|
|
],
|
|
},
|
|
{
|
|
id: "storage",
|
|
label: $t.nav?.storage || "STORAGE",
|
|
icon: "M4 4h16v16H4V4zm2 2v12h12V6H6zm2 2h8v2H8V8zm0 4h8v2H8v-2zm0 4h5v2H8v-2z",
|
|
path: "/storage",
|
|
subItems: [
|
|
{ label: $t.nav?.backups || "Backups", path: "/storage/backups" },
|
|
{
|
|
label: $t.nav?.repositories || "Repositories",
|
|
path: "/storage/repos",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "admin",
|
|
label: $t.nav?.admin || "ADMIN",
|
|
icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z",
|
|
path: "/admin",
|
|
subItems: [
|
|
{ label: $t.nav?.admin_users || "Users", path: "/admin/users" },
|
|
{ label: $t.nav?.admin_roles || "Roles", path: "/admin/roles" },
|
|
{ label: $t.nav?.settings || "Settings", path: "/settings" },
|
|
],
|
|
},
|
|
];
|
|
|
|
// 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="bg-white border-r border-gray-200 flex flex-col h-screen fixed left-0 top-0 z-30 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 p-4 border-b border-gray-200 {isExpanded
|
|
? 'justify-between'
|
|
: 'justify-center'}"
|
|
>
|
|
{#if isExpanded}
|
|
<span class="font-semibold text-gray-800">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 items-center justify-between px-4 py-3 cursor-pointer transition-colors hover:bg-gray-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">
|
|
<svg
|
|
class="w-5 h-5 shrink-0"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d={category.icon} />
|
|
</svg>
|
|
{#if isExpanded}
|
|
<span class="ml-3 text-sm font-medium truncate"
|
|
>{category.label}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
{#if isExpanded}
|
|
<svg
|
|
class="text-gray-400 transition-transform duration-200 {expandedCategories.has(
|
|
category.id,
|
|
)
|
|
? 'rotate-180'
|
|
: ''}"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
{/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}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
class="mr-2"
|
|
>
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
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="Expand sidebar"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M9 18l6-6-6-6" />
|
|
</svg>
|
|
<span class="ml-2">Expand</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- [/DEF:Sidebar:Component] -->
|