feat(layout): finalize p01 and p02 changes
Complete UI foundation and app layout implementation, stabilize container health checks, and archive both OpenSpec changes after verification.
This commit is contained in:
28
frontend/src/lib/components/layout/AppLayout.svelte
Normal file
28
frontend/src/lib/components/layout/AppLayout.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script context="module" lang="ts">
|
||||
import type { SidebarState } from '$lib/types/layout';
|
||||
|
||||
export function getContentOffsetClass(state: SidebarState): string {
|
||||
if (state === 'expanded') return 'md:ml-60';
|
||||
if (state === 'collapsed') return 'md:ml-16';
|
||||
return 'md:ml-0';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { sidebarState } from '$lib/stores/layout';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import TopBar from './TopBar.svelte';
|
||||
|
||||
$: contentOffsetClass = getContentOffsetClass($sidebarState);
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-base-200" data-testid="app-layout">
|
||||
<Sidebar />
|
||||
|
||||
<div class={`flex min-h-screen flex-col transition-all duration-200 ${contentOffsetClass}`} data-testid="layout-main">
|
||||
<TopBar />
|
||||
<main class="flex-1 p-4 md:p-6 overflow-auto" data-testid="layout-content">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
50
frontend/src/lib/components/layout/Breadcrumbs.svelte
Normal file
50
frontend/src/lib/components/layout/Breadcrumbs.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script context="module" lang="ts">
|
||||
export interface Breadcrumb {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function generateBreadcrumbs(pathname: string): Breadcrumb[] {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const crumbs: Breadcrumb[] = [{ label: 'Home', href: '/' }];
|
||||
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
current += `/${segment}`;
|
||||
crumbs.push({
|
||||
label: segment
|
||||
.split('-')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' '),
|
||||
href: current
|
||||
});
|
||||
}
|
||||
|
||||
return crumbs;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { Home } from 'lucide-svelte';
|
||||
|
||||
$: crumbs = generateBreadcrumbs($page.url.pathname);
|
||||
</script>
|
||||
|
||||
<nav class="breadcrumbs text-sm" aria-label="Breadcrumb" data-testid="breadcrumbs">
|
||||
<ul>
|
||||
{#each crumbs as crumb, i (crumb.href)}
|
||||
<li>
|
||||
{#if i === 0}
|
||||
<a href={crumb.href} aria-label="Home">
|
||||
<Home size={16} />
|
||||
</a>
|
||||
{:else if i === crumbs.length - 1}
|
||||
<span class="font-medium">{crumb.label}</span>
|
||||
{:else}
|
||||
<a href={crumb.href}>{crumb.label}</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
61
frontend/src/lib/components/layout/MonthSelector.svelte
Normal file
61
frontend/src/lib/components/layout/MonthSelector.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script context="module" lang="ts">
|
||||
export function formatMonth(period: string): string {
|
||||
const [year, month] = period.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, 1);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function generateMonthOptions(baseDate = new Date()): string[] {
|
||||
const options: string[] = [];
|
||||
for (let i = -6; i <= 6; i += 1) {
|
||||
const date = new Date(baseDate.getFullYear(), baseDate.getMonth() + i, 1);
|
||||
options.push(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import {
|
||||
currentMonth,
|
||||
nextMonth,
|
||||
previousMonth,
|
||||
selectedPeriod,
|
||||
setPeriod
|
||||
} from '$lib/stores/period';
|
||||
|
||||
const monthOptions = generateMonthOptions();
|
||||
$: displayMonth = formatMonth($selectedPeriod);
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-end" data-testid="month-selector">
|
||||
<button class="btn btn-sm btn-ghost gap-1" aria-label="Select month">
|
||||
<span class="font-medium">{displayMonth}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
|
||||
<ul class="menu menu-sm dropdown-content mt-2 z-30 w-48 rounded-box bg-base-100 p-2 shadow-lg border border-base-300">
|
||||
<li>
|
||||
<button class="justify-between" on:click={previousMonth}>
|
||||
<span>Previous</span>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li><button on:click={currentMonth}>Today</button></li>
|
||||
<li>
|
||||
<button class="justify-between" on:click={nextMonth}>
|
||||
<span>Next</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li><hr class="my-1 border-base-300" /></li>
|
||||
{#each monthOptions as option}
|
||||
<li>
|
||||
<button class:active={option === $selectedPeriod} on:click={() => setPeriod(option)}>
|
||||
{formatMonth(option)}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
132
frontend/src/lib/components/layout/Sidebar.svelte
Normal file
132
frontend/src/lib/components/layout/Sidebar.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<script context="module" lang="ts">
|
||||
import type { NavSection } from '$lib/types/layout';
|
||||
|
||||
export function isSectionVisible(section: NavSection, role?: string | null): boolean {
|
||||
if (!section.roles?.length) return true;
|
||||
if (!role) return false;
|
||||
return section.roles.includes(role);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Moon,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightOpen,
|
||||
Sun
|
||||
} from 'lucide-svelte';
|
||||
import { navigationSections } from '$lib/config/navigation';
|
||||
import {
|
||||
setSidebarState,
|
||||
sidebarState,
|
||||
theme,
|
||||
toggleSidebar,
|
||||
toggleTheme
|
||||
} from '$lib/stores/layout';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import SidebarSection from './SidebarSection.svelte';
|
||||
|
||||
$: isExpanded = $sidebarState === 'expanded';
|
||||
$: isCollapsed = $sidebarState === 'collapsed';
|
||||
$: isHidden = $sidebarState === 'hidden';
|
||||
$: isDrawerOpen = $sidebarState !== 'hidden';
|
||||
|
||||
$: visibleSections = navigationSections.filter((section) => isSectionVisible(section, $user?.role));
|
||||
|
||||
function toggleWithShortcut(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === '\\') {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-sidebar', $sidebarState);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($sidebarState === 'expanded') {
|
||||
if (window.innerWidth < 768) {
|
||||
setSidebarState('hidden');
|
||||
} else if (window.innerWidth < 1280) {
|
||||
setSidebarState('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', toggleWithShortcut);
|
||||
return () => window.removeEventListener('keydown', toggleWithShortcut);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="mobile-backdrop fixed inset-0 z-20 bg-black/40"
|
||||
class:hidden={!isDrawerOpen}
|
||||
aria-hidden={!isDrawerOpen}
|
||||
on:click={() => setSidebarState('hidden')}
|
||||
data-testid="sidebar-backdrop"
|
||||
></div>
|
||||
|
||||
<aside
|
||||
class="fixed left-0 top-0 z-30 flex h-full flex-col border-r border-base-300 bg-base-200 transition-all duration-200 ease-out"
|
||||
class:w-60={isExpanded}
|
||||
class:w-16={isCollapsed}
|
||||
class:w-0={isHidden}
|
||||
class:overflow-hidden={isHidden}
|
||||
class:-translate-x-full={isHidden}
|
||||
class:translate-x-0={!isHidden}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<div class="flex h-14 items-center justify-between border-b border-base-300 px-3">
|
||||
{#if isExpanded}
|
||||
<a href="/dashboard" class="text-lg font-bold">Headroom</a>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-ghost btn-sm btn-circle" on:click={toggleSidebar} aria-label="Toggle sidebar" data-testid="sidebar-toggle">
|
||||
{#if isExpanded}
|
||||
<PanelLeftClose size={18} />
|
||||
{:else if isCollapsed}
|
||||
<PanelLeftOpen size={18} />
|
||||
{:else}
|
||||
<PanelRightOpen size={18} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto py-2">
|
||||
{#each visibleSections as section (section.title)}
|
||||
<SidebarSection {section} expanded={isExpanded} />
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="border-t border-base-300 p-3">
|
||||
<button class="btn btn-ghost btn-sm w-full justify-start gap-2" class:justify-center={!isExpanded} on:click={toggleTheme} aria-label="Toggle theme" data-testid="theme-toggle">
|
||||
{#if $theme === 'light'}
|
||||
<Moon size={16} />
|
||||
{#if isExpanded}<span>Dark Mode</span>{/if}
|
||||
{:else}
|
||||
<Sun size={16} />
|
||||
{#if isExpanded}<span>Light Mode</span>{/if}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
aside {
|
||||
width: 15rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/lib/components/layout/SidebarItem.svelte
Normal file
55
frontend/src/lib/components/layout/SidebarItem.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Database,
|
||||
DollarSign,
|
||||
Folder,
|
||||
Grid3X3,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Users
|
||||
} from 'lucide-svelte';
|
||||
|
||||
const iconMap = {
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
Database,
|
||||
DollarSign,
|
||||
Folder,
|
||||
Grid3X3,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
Users
|
||||
} as const;
|
||||
|
||||
export let icon: string;
|
||||
export let label: string;
|
||||
export let href: string;
|
||||
export let expanded = true;
|
||||
|
||||
$: active = $page.url.pathname === href || $page.url.pathname.startsWith(`${href}/`);
|
||||
$: IconComponent = iconMap[icon as keyof typeof iconMap] ?? LayoutDashboard;
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="btn btn-ghost justify-start w-full h-10 rounded-lg"
|
||||
class:btn-active={active}
|
||||
class:justify-center={!expanded}
|
||||
href={href}
|
||||
title={!expanded ? label : undefined}
|
||||
aria-label={label}
|
||||
data-testid="sidebar-item"
|
||||
>
|
||||
<svelte:component this={IconComponent} size={18} />
|
||||
{#if expanded}
|
||||
<span class="truncate">{label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
21
frontend/src/lib/components/layout/SidebarSection.svelte
Normal file
21
frontend/src/lib/components/layout/SidebarSection.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { NavSection } from '$lib/types/layout';
|
||||
import SidebarItem from './SidebarItem.svelte';
|
||||
|
||||
export let section: NavSection;
|
||||
export let expanded = true;
|
||||
</script>
|
||||
|
||||
<section class="px-2 py-1" data-testid="sidebar-section">
|
||||
{#if expanded}
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-base-content/60 px-2 py-2">
|
||||
{section.title}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
{#each section.items as item (item.href)}
|
||||
<SidebarItem icon={item.icon} label={item.label} href={item.href} {expanded} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
32
frontend/src/lib/components/layout/TopBar.svelte
Normal file
32
frontend/src/lib/components/layout/TopBar.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Menu } from 'lucide-svelte';
|
||||
import { setSidebarState, sidebarState } from '$lib/stores/layout';
|
||||
import Breadcrumbs from './Breadcrumbs.svelte';
|
||||
import MonthSelector from './MonthSelector.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
|
||||
function toggleMobileSidebar() {
|
||||
setSidebarState($sidebarState === 'hidden' ? 'expanded' : 'hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-20 border-b border-base-300 bg-base-100/95 backdrop-blur" data-testid="topbar">
|
||||
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle md:hidden"
|
||||
aria-label="Toggle menu"
|
||||
on:click={toggleMobileSidebar}
|
||||
data-testid="mobile-hamburger"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<MonthSelector />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
32
frontend/src/lib/components/layout/UserMenu.svelte
Normal file
32
frontend/src/lib/components/layout/UserMenu.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { logout, user } from '$lib/stores/auth';
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-end" data-testid="user-menu">
|
||||
<button class="btn btn-ghost btn-circle avatar" aria-label="Open user menu">
|
||||
<div class="w-9 rounded-full bg-primary text-primary-content grid place-items-center">
|
||||
<span class="text-sm font-semibold">{$user?.email?.charAt(0).toUpperCase() ?? '?'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<ul class="menu menu-sm dropdown-content mt-2 z-30 w-52 rounded-box bg-base-100 p-2 shadow-lg border border-base-300">
|
||||
<li class="menu-title">
|
||||
<span>{$user?.email ?? 'Guest'}</span>
|
||||
</li>
|
||||
<li><a href="/dashboard">Dashboard</a></li>
|
||||
{#if $user?.role === 'superuser' || $user?.role === 'manager'}
|
||||
<li><a href="/team-members">Team Members</a></li>
|
||||
<li><a href="/projects">Projects</a></li>
|
||||
{/if}
|
||||
<li><a href="/reports">Reports</a></li>
|
||||
<li>
|
||||
<button class="text-error" on:click={handleLogout}>Logout</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
Reference in New Issue
Block a user