- Create gotoLogin() helper for login redirects with search params - Add /terms and /privacy public routes with Tailwind typography - Update auth-guard to allow unauthenticated access to public pages - Fix resolve() usage across navigation components using as const pattern - Fix eslint-disable-next-line placement for svelte/no-navigation-without-resolve - Document SvelteKit resolve() patterns in CLAUDE.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
198 lines
7.8 KiB
Svelte
198 lines
7.8 KiB
Svelte
<script lang="ts">
|
|
import { getUserInitials } from "@reviq/common";
|
|
import { createQuery, useQueryClient } from "@tanstack/svelte-query";
|
|
import { getContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { resolve } from "$app/paths";
|
|
import { page } from "$app/stores";
|
|
import { api } from "$lib/api/client";
|
|
import { Button } from "$lib/components/ui/button";
|
|
import { Separator } from "$lib/components/ui/separator";
|
|
import * as Sheet from "$lib/components/ui/sheet";
|
|
import { cn } from "$lib/utils.js";
|
|
|
|
interface Props {
|
|
class?: string;
|
|
}
|
|
|
|
let { class: className }: Props = $props();
|
|
|
|
let open = $state(false);
|
|
|
|
// Get optional org context (undefined outside org routes)
|
|
const orgContext = getContext<
|
|
{ slug: string; currentUserRole: string | null } | undefined
|
|
>("orgContext");
|
|
const currentSlug = $derived(orgContext?.slug);
|
|
const currentUserRole = $derived(orgContext?.currentUserRole);
|
|
|
|
// Fetch current user
|
|
const userQuery = createQuery(() => ({
|
|
queryKey: ["me"],
|
|
queryFn: () => api.me.get(),
|
|
}));
|
|
|
|
const user = $derived(userQuery.data);
|
|
const initials = $derived(getUserInitials(user));
|
|
|
|
// Nav items depend on whether we're in an org context
|
|
const navItems = $derived.by(() => {
|
|
if (currentSlug) {
|
|
return [
|
|
{ icon: "home", href: `/dashboard/${currentSlug}`, label: "Home" },
|
|
{
|
|
icon: "chart",
|
|
href: `/dashboard/${currentSlug}/performance`,
|
|
label: "Performance",
|
|
},
|
|
{
|
|
icon: "document",
|
|
href: `/dashboard/${currentSlug}/reports`,
|
|
label: "Reports",
|
|
},
|
|
];
|
|
}
|
|
return [
|
|
{ icon: "home", href: "/", label: "Home" },
|
|
{ icon: "building", href: "/dashboard", label: "Organizations" },
|
|
];
|
|
});
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
function handleNavClick(): void {
|
|
open = false;
|
|
}
|
|
|
|
async function handleSignOut(): Promise<void> {
|
|
try {
|
|
await api.auth.logout();
|
|
queryClient.clear();
|
|
open = false;
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
goto("/auth/login");
|
|
} catch (error) {
|
|
console.error("Failed to sign out:", error);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<Sheet.Root bind:open>
|
|
<Sheet.Trigger>
|
|
{#snippet child({ props })}
|
|
<Button variant="ghost" size="icon" class={cn("h-9 w-9 lg:hidden", className)} {...props}>
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
<path d="M3 12h18M3 6h18M3 18h18" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
<span class="sr-only">Open menu</span>
|
|
</Button>
|
|
{/snippet}
|
|
</Sheet.Trigger>
|
|
|
|
<Sheet.Content side="left" class="w-72 p-0">
|
|
<Sheet.Header class="border-b border-border px-6 py-4">
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-foreground">
|
|
<svg class="h-5 w-5 text-background" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
</div>
|
|
<Sheet.Title class="text-lg font-semibold">Publisher Dashboard</Sheet.Title>
|
|
</div>
|
|
</Sheet.Header>
|
|
|
|
<nav class="flex flex-1 flex-col p-4">
|
|
<div class="space-y-1">
|
|
{#each navItems as item (item.href)}
|
|
{@const isActive =
|
|
$page.url.pathname === item.href ||
|
|
(item.href !== "/" && $page.url.pathname.startsWith(item.href))}
|
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
|
<a href={item.href}
|
|
onclick={handleNavClick}
|
|
class={cn(
|
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
|
isActive
|
|
? "bg-accent text-foreground"
|
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
|
)}
|
|
>
|
|
{#if item.icon === "home"}
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" stroke-linecap="round" stroke-linejoin="round" />
|
|
<path d="M9 22V12h6v10" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
{:else if item.icon === "chart"}
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
<path d="M18 20V10M12 20V4M6 20v-6" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
{:else if item.icon === "document"}
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
<path
|
|
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
{:else if item.icon === "building"}
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
<path d="M3 21h18M5 21V5a2 2 0 012-2h10a2 2 0 012 2v16" stroke-linecap="round" stroke-linejoin="round" />
|
|
<path d="M9 6.5h1.5M9 10h1.5M9 13.5h1.5M13.5 6.5H15M13.5 10H15M13.5 13.5H15M9 21v-4h6v4" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
{/if}
|
|
{item.label}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- User section at bottom -->
|
|
<div class="mt-auto pt-4">
|
|
<Separator class="mb-4" />
|
|
<div class="flex items-center gap-3 rounded-lg px-3 py-2">
|
|
{#if user?.avatarUrl}
|
|
<img src={user.avatarUrl} alt="" class="h-9 w-9 rounded-full object-cover" />
|
|
{:else}
|
|
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-amber-500 to-orange-600 text-xs font-semibold text-white">
|
|
{initials}
|
|
</div>
|
|
{/if}
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-foreground">{user?.displayName ?? user?.email ?? "Loading..."}</p>
|
|
{#if currentUserRole}
|
|
<p class="text-xs capitalize text-muted-foreground">{currentUserRole}</p>
|
|
{:else if user?.email && user?.displayName}
|
|
<p class="text-xs text-muted-foreground">{user.email}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-2 space-y-1">
|
|
<a
|
|
href={resolve("/account")}
|
|
onclick={handleNavClick}
|
|
class="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
|
>
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke-linecap="round" stroke-linejoin="round" />
|
|
<circle cx="12" cy="7" r="4" />
|
|
</svg>
|
|
Account Settings
|
|
</a>
|
|
<button
|
|
onclick={handleSignOut}
|
|
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
|
|
>
|
|
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" stroke-linecap="round" stroke-linejoin="round" />
|
|
<polyline points="16,17 21,12 16,7" stroke-linecap="round" stroke-linejoin="round" />
|
|
<line x1="21" y1="12" x2="9" y2="12" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</Sheet.Content>
|
|
</Sheet.Root>
|