Files
root-org/src/routes/[orgSlug]/+layout.svelte
AlacrisDevs 2913912cb8 feat: UI overhaul - component library + route layouts with instant headers
- Created 11 reusable UI components: PageHeader, SectionCard, StatCard, StatusBadge, TabBar, MemberList, ActivityFeed, EventCard, ContentSkeleton, QuickLinkGrid, ModuleCard
- Created route-specific +layout.svelte for documents, calendar, kanban, events, settings, account
- Each layout renders PageHeader instantly from parent data, shows ContentSkeleton during navigation
- Removed full-page PageSkeleton from parent layout
- Refactored all pages to use new components instead of inline markup
- Overview page: uses StatCard, SectionCard, EventCard, ActivityFeed, MemberList, QuickLinkGrid
- Events list: uses EventCard, Button components
- Event detail: uses ModuleCard, SectionCard
- Settings/Account/Calendar/Kanban: headers in layouts, toolbars in pages
- Added i18n keys for overview page (EN + ET)
- 0 errors, 112 tests pass
2026-02-07 10:44:53 +02:00

352 lines
9.3 KiB
Svelte

<script lang="ts">
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { Snippet } from "svelte";
import { getContext } from "svelte";
import { on } from "svelte/events";
import { Avatar, Logo } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { hasPermission, type Permission } from "$lib/utils/permissions";
import { setContext } from "svelte";
import * as m from "$lib/paraglide/messages";
import { totalUnreadCount } from "$lib/stores/matrix";
interface Member {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
}
interface UserProfile {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
}
interface Props {
data: {
org: {
id: string;
name: string;
slug: string;
avatar_url?: string | null;
};
userRole: string;
userPermissions: string[] | null;
members: Member[];
profile: UserProfile;
};
children: Snippet;
}
let { data, children }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
// Provide a permission checker via context so any child component can use it
const canAccess = (permission: Permission | string): boolean =>
hasPermission(data.userRole, data.userPermissions, permission);
setContext("canAccess", canAccess);
// Sidebar collapses on all pages except org overview
const isOrgOverview = $derived($page.url.pathname === `/${data.org.slug}`);
let sidebarHovered = $state(false);
const sidebarCollapsed = $derived(!isOrgOverview && !sidebarHovered);
// User dropdown
let showUserMenu = $state(false);
let menuContainerEl = $state<HTMLElement | null>(null);
// Attach click-outside and Escape listeners only while menu is open.
// Uses svelte/events 'on' to respect Svelte 5 event delegation order.
$effect(() => {
if (!showUserMenu) return;
// Defer so the opening click doesn't immediately close the menu
const timer = setTimeout(() => {
cleanupClick = on(document, "click", (e: MouseEvent) => {
if (
menuContainerEl &&
!menuContainerEl.contains(e.target as Node)
) {
showUserMenu = false;
}
});
}, 0);
const cleanupKey = on(document, "keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Escape") showUserMenu = false;
});
let cleanupClick: (() => void) | undefined;
return () => {
clearTimeout(timer);
cleanupClick?.();
cleanupKey();
};
});
async function handleLogout() {
await supabase.auth.signOut();
goto("/");
}
const navItems = $derived([
...(canAccess("documents.view")
? [
{
href: `/${data.org.slug}/documents`,
label: m.nav_files(),
icon: "cloud",
},
]
: []),
...(canAccess("calendar.view")
? [
{
href: `/${data.org.slug}/calendar`,
label: m.nav_calendar(),
icon: "calendar_today",
},
]
: []),
{
href: `/${data.org.slug}/events`,
label: m.nav_events(),
icon: "celebration",
},
{
href: `/${data.org.slug}/chat`,
label: "Chat",
icon: "chat",
badge: $totalUnreadCount > 0 ? ($totalUnreadCount > 99 ? "99+" : String($totalUnreadCount)) : null,
},
// Settings requires settings.view or admin role
...(canAccess("settings.view")
? [
{
href: `/${data.org.slug}/settings`,
label: m.nav_settings(),
icon: "settings",
},
]
: []),
]);
function isActive(href: string): boolean {
return $page.url.pathname.startsWith(href);
}
</script>
<!-- Figma-matched layout: bg-background with gap-4 padding -->
<div class="flex h-screen bg-background p-4 gap-4">
<!-- Organization Module -->
<aside
class="
{sidebarCollapsed ? 'w-[72px]' : 'w-64'}
transition-all duration-300
bg-night rounded-[32px] flex flex-col px-4 py-5 gap-4 overflow-hidden shrink-0
"
onmouseenter={() => (sidebarHovered = true)}
onmouseleave={() => (sidebarHovered = false)}
>
<!-- Org Header -->
<a
href="/{data.org.slug}"
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors"
>
<div
class="shrink-0 transition-all duration-300 {sidebarCollapsed
? 'w-8 h-8'
: 'w-12 h-12'}"
>
<Avatar
name={data.org.name}
src={data.org.avatar_url}
size="md"
/>
</div>
<div
class="min-w-0 flex-1 overflow-hidden transition-all duration-300 {sidebarCollapsed
? 'opacity-0 max-w-0'
: 'opacity-100 max-w-[200px]'}"
>
<h1
class="font-heading text-h3 text-white truncate whitespace-nowrap"
>
{data.org.name}
</h1>
<p
class="text-body-sm text-white font-body capitalize whitespace-nowrap"
>
{data.userRole}
</p>
</div>
</a>
<!-- Nav Items -->
<nav class="flex-1 flex flex-col gap-1">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] transition-colors {isActive(
item.href,
)
? 'bg-primary'
: 'hover:bg-dark'}"
title={sidebarCollapsed ? item.label : undefined}
>
<div
class="w-8 h-8 flex items-center justify-center p-1 shrink-0"
>
<span
class="material-symbols-rounded {isActive(item.href)
? 'text-background'
: 'text-light'}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{item.icon}
</span>
</div>
<span
class="font-body text-body truncate whitespace-nowrap transition-all duration-300 {isActive(
item.href,
)
? 'text-background'
: 'text-white'} {sidebarCollapsed
? 'opacity-0 max-w-0 overflow-hidden'
: 'opacity-100 max-w-[200px]'}">{item.label}</span
>
{#if item.badge}
<span class="ml-auto bg-primary text-background text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shrink-0">
{item.badge}
</span>
{/if}
</a>
{/each}
</nav>
<!-- User Section + Logo at bottom -->
<div class="mt-auto flex flex-col gap-3">
<!-- User Avatar + Quick Menu -->
<div
class="relative user-menu-container"
bind:this={menuContainerEl}
>
<button
type="button"
class="flex items-center gap-2 p-1 rounded-[32px] hover:bg-dark transition-colors w-full"
onclick={() => (showUserMenu = !showUserMenu)}
aria-expanded={showUserMenu}
aria-haspopup="true"
>
<div
class="shrink-0 transition-all duration-300 {sidebarCollapsed
? 'w-8 h-8'
: 'w-10 h-10'}"
>
<Avatar
name={data.profile.full_name || data.profile.email}
src={data.profile.avatar_url}
size="sm"
/>
</div>
<div
class="min-w-0 flex-1 overflow-hidden text-left transition-all duration-300 {sidebarCollapsed
? 'opacity-0 max-w-0'
: 'opacity-100 max-w-[200px]'}"
>
<p
class="font-body text-body-sm text-white truncate whitespace-nowrap leading-tight"
>
{data.profile.full_name || "User"}
</p>
<p
class="font-body text-[11px] text-light/50 truncate whitespace-nowrap leading-tight"
>
{data.profile.email}
</p>
</div>
</button>
{#if showUserMenu}
<div
class="absolute bottom-full left-0 mb-2 py-1 bg-dark border border-light/10 rounded-xl shadow-xl min-w-[200px] z-50"
>
<a
href="/{data.org.slug}/account"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
person
</span>
<span>{m.user_menu_account_settings()}</span>
</a>
<a
href="/"
class="flex items-center gap-3 px-3 py-2 text-sm text-light hover:bg-light/5 transition-colors"
onclick={() => (showUserMenu = false)}
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
swap_horiz
</span>
<span>{m.user_menu_switch_org()}</span>
</a>
<div class="border-t border-light/10 my-1"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors"
onclick={handleLogout}
>
<span
class="material-symbols-rounded text-error/60"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
logout
</span>
<span>{m.user_menu_logout()}</span>
</button>
</div>
{/if}
</div>
<!-- Logo -->
<a
href="/"
title="Back to organizations"
class="flex items-center justify-center"
>
<Logo
size={sidebarCollapsed ? "sm" : "md"}
showText={!sidebarCollapsed}
/>
</a>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-hidden relative">
{@render children()}
</main>
</div>