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">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { DropdownMenu } from 'bits-ui';
|
||||
import ThemeToggle from './ThemeToggle.svelte';
|
||||
import LanguageSwitcher from './LanguageSwitcher.svelte';
|
||||
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
|
||||
import NotificationBell from '$lib/components/notifications/NotificationBell.svelte';
|
||||
import { ui } from '$lib/stores/ui.svelte.js';
|
||||
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
|
||||
@@ -13,8 +15,10 @@
|
||||
|
||||
let { user }: Props = $props();
|
||||
|
||||
let showUserMenu = $state(false);
|
||||
let showBgMenu = $state(false);
|
||||
function submitLogout() {
|
||||
const form = document.getElementById('logout-form');
|
||||
if (form instanceof HTMLFormElement) form.requestSubmit();
|
||||
}
|
||||
|
||||
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
|
||||
{ value: 'mesh', labelKey: 'bg.mesh' },
|
||||
@@ -22,20 +26,8 @@
|
||||
{ value: 'aurora', labelKey: 'bg.aurora' },
|
||||
{ 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>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<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"
|
||||
>
|
||||
@@ -70,10 +62,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Background selector -->
|
||||
<div class="bg-menu-container relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showBgMenu = !showBgMenu)}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
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')}
|
||||
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" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showBgMenu}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="z-50 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
sideOffset={4}
|
||||
align="end"
|
||||
>
|
||||
{#each bgOptions as opt (opt.value)}
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
<DropdownMenu.Item
|
||||
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
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-popover-foreground hover:bg-accent/50'}"
|
||||
onSelect={() => theme.setBackground(opt.value)}
|
||||
>
|
||||
{#if theme.backgroundType === opt.value}
|
||||
<svg
|
||||
@@ -123,11 +110,11 @@
|
||||
<span class="h-3 w-3"></span>
|
||||
{/if}
|
||||
{$t(opt.labelKey)}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<!-- Notifications bell (authenticated users only) -->
|
||||
{#if user}
|
||||
@@ -142,10 +129,8 @@
|
||||
|
||||
<!-- User menu -->
|
||||
{#if user}
|
||||
<div class="user-menu-container relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showUserMenu = !showUserMenu)}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<span
|
||||
@@ -156,21 +141,21 @@
|
||||
{#if !ui.isMobile}
|
||||
<span class="max-w-[120px] truncate text-sm">{user.displayName}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showUserMenu}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="z-50 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">
|
||||
<p class="text-sm font-medium text-popover-foreground">{user.displayName}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/settings"
|
||||
onclick={() => (showUserMenu = false)}
|
||||
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"
|
||||
<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={() => goto('/settings')}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -186,12 +171,11 @@
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
{$t('settings.title')}
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<a
|
||||
href="/settings/api-tokens"
|
||||
onclick={() => (showUserMenu = false)}
|
||||
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"
|
||||
<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={() => goto('/settings/api-tokens')}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -207,12 +191,14 @@
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
API Tokens
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
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.Separator class="my-1 border-t border-border" />
|
||||
|
||||
<form method="POST" action="/auth/logout" id="logout-form" class="contents">
|
||||
<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
|
||||
class="h-4 w-4"
|
||||
@@ -229,11 +215,11 @@
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
{$t('auth.logout')}
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<a
|
||||
href="/login"
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
let { boards, isAdmin, collapsed }: Props = $props();
|
||||
|
||||
let boardsExpanded = $state(true);
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
return $page.url.pathname.startsWith(path);
|
||||
}
|
||||
@@ -158,36 +160,54 @@
|
||||
{#if boards.length > 0}
|
||||
<div class="mb-3">
|
||||
{#if !collapsed}
|
||||
<p
|
||||
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
|
||||
<button
|
||||
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')}
|
||||
</p>
|
||||
<span>{$t('nav.boards')}</span>
|
||||
<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}
|
||||
|
||||
{#each boards as board (board.id)}
|
||||
<a
|
||||
href="/boards/{board.id}"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
title={collapsed ? board.name : undefined}
|
||||
onclick={() => ui.closeMobileSidebar()}
|
||||
>
|
||||
{#if board.icon}
|
||||
<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"
|
||||
{#if boardsExpanded || collapsed}
|
||||
<div class="max-h-48 overflow-y-auto">
|
||||
{#each boards as board (board.id)}
|
||||
<a
|
||||
href="/boards/{board.id}"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
|
||||
? 'bg-sidebar-accent text-sidebar-accent-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
|
||||
title={collapsed ? board.name : undefined}
|
||||
onclick={() => ui.closeMobileSidebar()}
|
||||
>
|
||||
{board.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !collapsed}
|
||||
<span class="truncate">{board.name}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{#if board.icon}
|
||||
<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()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !collapsed}
|
||||
<span class="truncate">{board.name}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user