- 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
352 lines
9.3 KiB
Svelte
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>
|