refactor: header user menu with bits-ui dropdown, collapsible sidebar boards
- Replace manual click-outside menu with DropdownMenu from bits-ui - Add collapsible boards section in sidebar with chevron toggle - Add max-height scroll for boards list
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
import { DropdownMenu } from 'bits-ui';
|
||||||
import ThemeToggle from './ThemeToggle.svelte';
|
import ThemeToggle from './ThemeToggle.svelte';
|
||||||
import LanguageSwitcher from './LanguageSwitcher.svelte';
|
import LanguageSwitcher from './LanguageSwitcher.svelte';
|
||||||
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
|
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
|
||||||
import NotificationBell from '$lib/components/notifications/NotificationBell.svelte';
|
import NotificationBell from '$lib/components/notifications/NotificationBell.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte.js';
|
import { ui } from '$lib/stores/ui.svelte.js';
|
||||||
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
|
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
|
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
|
||||||
@@ -13,8 +15,10 @@
|
|||||||
|
|
||||||
let { user }: Props = $props();
|
let { user }: Props = $props();
|
||||||
|
|
||||||
let showUserMenu = $state(false);
|
function submitLogout() {
|
||||||
let showBgMenu = $state(false);
|
const form = document.getElementById('logout-form');
|
||||||
|
if (form instanceof HTMLFormElement) form.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
|
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
|
||||||
{ value: 'mesh', labelKey: 'bg.mesh' },
|
{ value: 'mesh', labelKey: 'bg.mesh' },
|
||||||
@@ -22,20 +26,8 @@
|
|||||||
{ value: 'aurora', labelKey: 'bg.aurora' },
|
{ value: 'aurora', labelKey: 'bg.aurora' },
|
||||||
{ value: 'none', labelKey: 'bg.none' }
|
{ value: 'none', labelKey: 'bg.none' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (!target.closest('.user-menu-container')) {
|
|
||||||
showUserMenu = false;
|
|
||||||
}
|
|
||||||
if (!target.closest('.bg-menu-container')) {
|
|
||||||
showBgMenu = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onclick={handleClickOutside} />
|
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-sm"
|
class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
@@ -70,10 +62,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Background selector -->
|
<!-- Background selector -->
|
||||||
<div class="bg-menu-container relative">
|
<DropdownMenu.Root>
|
||||||
<button
|
<DropdownMenu.Trigger
|
||||||
type="button"
|
|
||||||
onclick={() => (showBgMenu = !showBgMenu)}
|
|
||||||
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
title={$t('bg.title')}
|
title={$t('bg.title')}
|
||||||
aria-label={$t('bg.aria_label')}
|
aria-label={$t('bg.aria_label')}
|
||||||
@@ -91,22 +81,19 @@
|
|||||||
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" />
|
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" />
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
{#if showBgMenu}
|
<DropdownMenu.Content
|
||||||
<div
|
class="z-50 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||||
class="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
|
sideOffset={4}
|
||||||
|
align="end"
|
||||||
>
|
>
|
||||||
{#each bgOptions as opt (opt.value)}
|
{#each bgOptions as opt (opt.value)}
|
||||||
<button
|
<DropdownMenu.Item
|
||||||
type="button"
|
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
|
||||||
onclick={() => {
|
|
||||||
theme.setBackground(opt.value);
|
|
||||||
showBgMenu = false;
|
|
||||||
}}
|
|
||||||
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
|
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-popover-foreground hover:bg-accent/50'}"
|
: 'text-popover-foreground hover:bg-accent/50'}"
|
||||||
|
onSelect={() => theme.setBackground(opt.value)}
|
||||||
>
|
>
|
||||||
{#if theme.backgroundType === opt.value}
|
{#if theme.backgroundType === opt.value}
|
||||||
<svg
|
<svg
|
||||||
@@ -123,11 +110,11 @@
|
|||||||
<span class="h-3 w-3"></span>
|
<span class="h-3 w-3"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{$t(opt.labelKey)}
|
{$t(opt.labelKey)}
|
||||||
</button>
|
</DropdownMenu.Item>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</DropdownMenu.Content>
|
||||||
{/if}
|
</DropdownMenu.Portal>
|
||||||
</div>
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
<!-- Notifications bell (authenticated users only) -->
|
<!-- Notifications bell (authenticated users only) -->
|
||||||
{#if user}
|
{#if user}
|
||||||
@@ -142,10 +129,8 @@
|
|||||||
|
|
||||||
<!-- User menu -->
|
<!-- User menu -->
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="user-menu-container relative">
|
<DropdownMenu.Root>
|
||||||
<button
|
<DropdownMenu.Trigger
|
||||||
type="button"
|
|
||||||
onclick={() => (showUserMenu = !showUserMenu)}
|
|
||||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -156,21 +141,21 @@
|
|||||||
{#if !ui.isMobile}
|
{#if !ui.isMobile}
|
||||||
<span class="max-w-[120px] truncate text-sm">{user.displayName}</span>
|
<span class="max-w-[120px] truncate text-sm">{user.displayName}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
{#if showUserMenu}
|
<DropdownMenu.Content
|
||||||
<div
|
class="z-50 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||||
class="absolute right-0 top-full mt-1 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
|
sideOffset={4}
|
||||||
|
align="end"
|
||||||
>
|
>
|
||||||
<div class="border-b border-border px-3 py-2">
|
<div class="border-b border-border px-3 py-2">
|
||||||
<p class="text-sm font-medium text-popover-foreground">{user.displayName}</p>
|
<p class="text-sm font-medium text-popover-foreground">{user.displayName}</p>
|
||||||
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
|
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<DropdownMenu.Item
|
||||||
href="/settings"
|
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||||
onclick={() => (showUserMenu = false)}
|
onSelect={() => goto('/settings')}
|
||||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -186,12 +171,11 @@
|
|||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
{$t('settings.title')}
|
{$t('settings.title')}
|
||||||
</a>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
<a
|
<DropdownMenu.Item
|
||||||
href="/settings/api-tokens"
|
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||||
onclick={() => (showUserMenu = false)}
|
onSelect={() => goto('/settings/api-tokens')}
|
||||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -207,12 +191,14 @@
|
|||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
API Tokens
|
API Tokens
|
||||||
</a>
|
</DropdownMenu.Item>
|
||||||
|
|
||||||
<form method="POST" action="/auth/logout">
|
<DropdownMenu.Separator class="my-1 border-t border-border" />
|
||||||
<button
|
|
||||||
type="submit"
|
<form method="POST" action="/auth/logout" id="logout-form" class="contents">
|
||||||
class="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
<DropdownMenu.Item
|
||||||
|
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
|
||||||
|
onSelect={submitLogout}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4"
|
class="h-4 w-4"
|
||||||
@@ -229,11 +215,11 @@
|
|||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
{$t('auth.logout')}
|
{$t('auth.logout')}
|
||||||
</button>
|
</DropdownMenu.Item>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</DropdownMenu.Content>
|
||||||
{/if}
|
</DropdownMenu.Portal>
|
||||||
</div>
|
</DropdownMenu.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
let { boards, isAdmin, collapsed }: Props = $props();
|
let { boards, isAdmin, collapsed }: Props = $props();
|
||||||
|
|
||||||
|
let boardsExpanded = $state(true);
|
||||||
|
|
||||||
function isActive(path: string): boolean {
|
function isActive(path: string): boolean {
|
||||||
return $page.url.pathname.startsWith(path);
|
return $page.url.pathname.startsWith(path);
|
||||||
}
|
}
|
||||||
@@ -158,36 +160,54 @@
|
|||||||
{#if boards.length > 0}
|
{#if boards.length > 0}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<p
|
<button
|
||||||
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
|
type="button"
|
||||||
|
onclick={() => (boardsExpanded = !boardsExpanded)}
|
||||||
|
class="mb-1 flex w-full items-center justify-between px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground/80"
|
||||||
>
|
>
|
||||||
{$t('nav.boards')}
|
<span>{$t('nav.boards')}</span>
|
||||||
</p>
|
<svg
|
||||||
|
class="h-3 w-3 transition-transform duration-200"
|
||||||
|
class:rotate-180={boardsExpanded}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#each boards as board (board.id)}
|
{#if boardsExpanded || collapsed}
|
||||||
<a
|
<div class="max-h-48 overflow-y-auto">
|
||||||
href="/boards/{board.id}"
|
{#each boards as board (board.id)}
|
||||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
|
<a
|
||||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
href="/boards/{board.id}"
|
||||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
|
||||||
title={collapsed ? board.name : undefined}
|
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||||
onclick={() => ui.closeMobileSidebar()}
|
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||||
>
|
title={collapsed ? board.name : undefined}
|
||||||
{#if board.icon}
|
onclick={() => ui.closeMobileSidebar()}
|
||||||
<span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
|
|
||||||
>
|
>
|
||||||
{board.name.charAt(0).toUpperCase()}
|
{#if board.icon}
|
||||||
</span>
|
<span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
|
||||||
{/if}
|
{:else}
|
||||||
{#if !collapsed}
|
<span
|
||||||
<span class="truncate">{board.name}</span>
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
|
||||||
{/if}
|
>
|
||||||
</a>
|
{board.name.charAt(0).toUpperCase()}
|
||||||
{/each}
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if !collapsed}
|
||||||
|
<span class="truncate">{board.name}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user