Files
descrybe/apps/web/src/lib/components/site/SiteHeader.svelte
T

264 lines
7.5 KiB
Svelte
Raw Normal View History

<script lang="ts">
import { onMount } from "svelte";
import { Button, buttonClasses } from "$lib/components/ui";
import { api } from "$lib/api";
import type { MeResponse } from "$lib/types";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { i18n } from "$lib/i18n";
import { NAV_ITEMS } from "./data";
let mobileMenuOpen = $state(false);
let me = $state<MeResponse | null>(null);
let authReady = $state(false);
const loggedIn = $derived(me != null);
const accountLabel = $derived.by(() => {
const company = me?.company?.name?.trim();
if (company) return company;
const name = me?.user?.name?.trim();
if (name) return name;
const email = me?.user?.email?.trim();
if (email) return email;
return i18n.t("site.account");
});
function navTitle(href: string): string {
if (href === "/") return i18n.t("site.nav.home");
if (href === "/pricing") return i18n.t("site.nav.pricing");
if (href === "/docs") return i18n.t("site.nav.apiDocs");
return href;
}
onMount(() => {
const ac = new AbortController();
const timer = window.setTimeout(() => ac.abort(), 8_000);
void api<MeResponse>("/api/auth/me", { signal: ac.signal })
.then((res) => {
me = res;
})
.catch(() => {
me = null;
})
.finally(() => {
window.clearTimeout(timer);
authReady = true;
});
return () => {
ac.abort();
window.clearTimeout(timer);
};
});
function scrollToSection(href: string) {
if (!href.startsWith("#")) return;
const targetId = href.slice(1);
const element = document.getElementById(targetId);
if (!element) return;
const headerOffset = 100;
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition = elementPosition + window.pageYOffset - headerOffset;
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
mobileMenuOpen = false;
}
function onNavClick(event: MouseEvent, href: string) {
if (href.startsWith("#")) {
event.preventDefault();
scrollToSection(href);
return;
}
const hashIdx = href.indexOf("#");
if (hashIdx >= 0) {
const path = href.slice(0, hashIdx) || "/";
const hash = href.slice(hashIdx);
const here = typeof window !== "undefined" ? window.location.pathname : "";
if (path === "/" || path === here) {
event.preventDefault();
if (here !== "/" && path === "/") {
window.location.assign(href);
return;
}
scrollToSection(hash);
return;
}
}
mobileMenuOpen = false;
}
</script>
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[60] focus:rounded-md focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-text focus:shadow-md focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-ring"
>
{i18n.t("a11y.skipToContent")}
</a>
<header
class="fixed top-0 right-0 left-0 z-[110] border-b border-border/80 bg-background/90 backdrop-blur-md"
>
<div class="container mx-auto max-w-7xl">
<div class="flex h-16 items-center justify-between px-4 sm:px-0">
<a href="/" class="group flex items-center space-x-3" aria-label={i18n.t("app.home")}>
<div
class="relative flex h-9 w-9 items-center justify-center transition-transform duration-200 group-hover:scale-105"
>
<img
src="/descrybe_logo.png"
alt=""
width="36"
height="36"
class="h-auto w-auto"
/>
</div>
<span class="hidden text-xl font-bold text-text sm:inline-block">{i18n.t("app.name")}</span>
</a>
<div class="absolute left-1/2 hidden -translate-x-1/2 transform lg:block">
<nav class="flex items-center space-x-10" aria-label={i18n.t("site.nav.primary")}>
{#each NAV_ITEMS as item}
<a
href={item.href}
class="text-sm font-medium text-text-muted transition-colors hover:text-link"
onclick={(e) => onNavClick(e, item.href)}
>
{navTitle(item.href)}
</a>
{/each}
</nav>
</div>
<div class="flex items-center gap-1 sm:gap-2" data-auth-ready={authReady}>
<ThemeToggle />
<div class="hidden items-center gap-2 lg:flex">
{#if loggedIn}
<span
class="max-w-[10rem] truncate text-sm font-medium text-text-muted"
title={accountLabel}>{accountLabel}</span
>
<a
href="/dashboard"
class={buttonClasses(
"default",
"sm",
"box-border h-9 min-h-9 border border-transparent px-4 text-sm font-medium leading-none"
)}>{i18n.t("site.goToApp")}</a
>
{:else}
<a
href="/login"
class={buttonClasses(
"ghost",
"sm",
"box-border h-9 min-h-9 px-4 text-sm font-medium leading-none"
)}>{i18n.t("site.logIn")}</a
>
<a
href="/register"
class={buttonClasses(
"default",
"sm",
"box-border h-9 min-h-9 border border-transparent px-4 text-sm font-medium leading-none"
)}>{i18n.t("site.getStarted")}</a
>
{/if}
</div>
<Button
variant="ghost"
size="icon"
class="ml-0 lg:hidden"
aria-label={mobileMenuOpen ? i18n.t("nav.closeMenu") : i18n.t("nav.open")}
aria-expanded={mobileMenuOpen}
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
>
{#if mobileMenuOpen}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-6 w-6"
aria-hidden="true"
><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-6 w-6"
aria-hidden="true"
><line x1="3" x2="21" y1="6" y2="6" /><line x1="3" x2="21" y1="12" y2="12" /><line
x1="3"
x2="21"
y1="18"
y2="18"
/></svg
>
{/if}
</Button>
</div>
</div>
</div>
{#if mobileMenuOpen}
<div class="absolute top-full right-0 left-0 bg-background/95 shadow-md backdrop-blur-md lg:hidden">
<div class="border-t border-border/80 px-4 pt-2 pb-6">
<nav class="mt-2 mb-4 flex flex-col space-y-1" aria-label={i18n.t("site.nav.mobile")}>
{#each NAV_ITEMS as item}
<a
href={item.href}
class="block py-2.5 text-base font-medium text-text transition-colors hover:text-link"
onclick={(e) => onNavClick(e, item.href)}
>
{navTitle(item.href)}
</a>
{/each}
</nav>
<div class="mt-4 space-y-3">
{#if loggedIn}
<p class="truncate text-center text-sm font-medium text-text-muted">
{accountLabel}
</p>
<a
href="/dashboard"
class={buttonClasses(
"default",
"default",
"box-border h-10 w-full min-h-10 text-sm font-medium"
)}>{i18n.t("site.goToApp")}</a
>
{:else}
<a
href="/login"
class={buttonClasses(
"ghost",
"default",
"box-border h-10 w-full min-h-10 text-sm font-medium"
)}>{i18n.t("site.logIn")}</a
>
<a
href="/register"
class={buttonClasses(
"default",
"default",
"box-border h-10 w-full min-h-10 text-sm font-medium"
)}>{i18n.t("site.getStarted")}</a
>
{/if}
</div>
</div>
</div>
{/if}
</header>