<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const topbar = tv({
slots: {
nav: "h-10 w-full px-lg",
row: "flex h-full items-center justify-between gap-md",
brand: "flex shrink-0 items-center gap-xs",
logo: "flex shrink-0 items-center gap-xs no-underline hover:no-underline",
wordmark: "hidden sm:inline-flex",
mark: "inline-flex sm:hidden",
actions: "flex items-center",
action: "flex items-center",
divider: "hidden h-6 w-px self-center rounded-md bg-background-inset md:block",
link: "flex items-center text-foreground-default transition-colors hover:text-foreground-muted",
identity: "flex items-center gap-2xs",
ball: "flex size-8 items-center justify-center rounded-full border border-border-default bg-background-inset text-foreground-subtle md:size-5",
bar: "hidden h-4 w-24 rounded-md bg-background-inset md:block",
switchHeader:
"border-b-0 px-xs py-1.5 typography-paragraph-small text-foreground-muted",
addBall:
"flex size-5.25 shrink-0 items-center justify-center rounded-full bg-background-muted text-foreground-default",
sheet: "flex flex-col [&>hr]:my-1",
panes: "-mx-md grid overflow-clip transition-[height] duration-250 ease-out-strong motion-reduce:transition-none",
pane: "col-start-1 row-start-1 self-start px-md"
},
variants: {
variant: {
marketing: {
nav: "mx-auto h-14 max-w-page rounded-2xl border border-border-default bg-background-default/80 px-sm shadow-small backdrop-blur-md",
actions: "gap-xs"
},
app: {
nav: "border-b border-border-default bg-background-default",
actions: "gap-xs"
}
}
}
});
export type TopbarVariants = VariantProps<typeof topbar>;
</script>
<script lang="ts">
import { profileIdentifier } from "#lib/api/identity.ts";
import { resolve } from "$app/paths";
import type { ResolvedPathname } from "$app/types";
import Button from "#lib/components/ui/Button.svelte";
import Dropdown from "#lib/components/ui/Dropdown.svelte";
import DropdownItem, { dropdownItem } from "#lib/components/ui/DropdownItem.svelte";
import Modal from "#lib/components/ui/Modal.svelte";
import { mobile } from "#lib/mobile.svelte.ts";
import DropdownSubmenu from "#lib/components/ui/DropdownSubmenu.svelte";
import DropdownGroup from "#lib/components/ui/DropdownGroup.svelte";
import ArrowRight from "$icon/arrow-right";
import BookMarked from "$icon/book-marked";
import BookBookmark from "$icon/book-bookmark";
import Building2 from "$icon/building-2";
import Download from "$icon/download";
import Cog from "$icon/cog";
import Info from "$icon/info";
import LineSquiggle from "$icon/line-squiggle";
import LogOut from "$icon/log-out";
import Plus from "$icon/plus";
import Search from "$icon/search";
import UserIcon from "$icon/user";
import Spinner from "#lib/components/ui/Spinner.svelte";
import Logo from "#lib/components/ui/Logo.svelte";
import User from "#lib/components/ui/User.svelte";
import TopbarSearch from "#lib/components/shell/TopbarSearch.svelte";
import NotificationBell from "#lib/components/notifications/NotificationBell.svelte";
import { isHandle } from "@atcute/lexicons/syntax";
import type { AuthAccount, CurrentUser } from "#lib/auth.svelte.ts";
import Separator from "#lib/components/ui/Separator.svelte";
import { setContext, type Snippet } from "svelte";
import { quintOut } from "svelte/easing";
import { prefersReducedMotion } from "svelte/motion";
import { fly } from "svelte/transition";
import ChevronLeft from "$icon/chevron-left";
import ChevronRight from "$icon/chevron-right";
import { buttonIcon } from "#lib/components/ui/Button.svelte";
import { siteLinkSections, type SiteLink } from "#lib/components/shell/siteLinks.ts";
import StatusDot from "#lib/components/StatusDot.svelte";
interface Props {
variant: NonNullable<TopbarVariants["variant"]>;
onSignOut?: () => void | Promise<void>;
onSwitchAccount?: (did: AuthAccount["did"]) => void | Promise<void>;
user: CurrentUser | null;
accounts?: readonly AuthAccount[];
loginHref?: string;
loading?: boolean;
}
let {
variant,
onSignOut,
onSwitchAccount,
user,
accounts = [],
loginHref = "/login",
loading = false
}: Props = $props();
const appPath = (path: string) => resolve(path as "/");
const siteLinkHref = (link: SiteLink) =>
link.internal ? appPath(link.href) : (link.href as ResolvedPathname);
let newSheetOpen = $state(false);
let searchSheetOpen = $state(false);
let profileSheetOpen = $state(false);
let aboutOpen = $state(false);
let mainHeight = $state<number>();
let aboutHeight = $state<number>();
const paneHeight = $derived(
aboutOpen ? (aboutHeight ?? mainHeight) : (mainHeight ?? aboutHeight)
);
setContext("dropdown-close", () => {
newSheetOpen = false;
profileSheetOpen = false;
});
const slide = (x: string) => ({
x: prefersReducedMotion.current ? 0 : x,
duration: 250,
easing: quintOut
});
const openProfileSheet = () => {
aboutOpen = false;
profileSheetOpen = true;
};
const others = $derived(accounts.filter((account) => account.did !== user?.did));
const style = $derived(topbar({ variant }));
</script>
<nav class={style.nav()} aria-label="main">
<div class={style.row()}>
<div class={style.brand()}>
<a href={resolve("/")} class={style.logo()}>
<span class={style.wordmark()}>
<Logo wordmark class="h-6" />
</span>
<span class={style.mark()}>
<img src="/logos/dolly.svg" alt="tangled" class="size-6" />
</span>
</a>
</div>
<div class={style.actions()}>
{#if variant === "marketing"}
<Button variant="ghost" href={resolve("/login")}>Sign in</Button>
<Button
variant="primary"
href={resolve("/signup")}
icon={ArrowRight}
iconSide="right"
>
Join now
</Button>
{:else}
{#if user || loading}
<div class={style.action({ class: "order-3 hidden sm:block md:order-1" })}>
<TopbarSearch />
</div>
<div class={style.action({ class: "order-1 md:order-3" })}>
{#snippet newItems()}
<DropdownItem href={resolve("/repo/new")} icon={BookBookmark}
>New repository</DropdownItem
>
<DropdownItem href={appPath("/repo/new?import=github")} icon={Download}
>Import repository</DropdownItem
>
<Separator />
<DropdownItem href={resolve("/strings/new")} icon={LineSquiggle}
>New string</DropdownItem
>
<Separator />
<DropdownItem href={resolve("/organizations/new")} icon={Building2}
>New organization</DropdownItem
>
{/snippet}
{#if mobile.current}
<Button
size="small"
aria-label="New"
aria-haspopup="dialog"
aria-expanded={newSheetOpen}
onclick={() => (newSheetOpen = true)}
>
<Plus class="size-3.5" aria-hidden="true" />
</Button>
<Modal drawer bind:open={newSheetOpen}>
<div class={style.sheet()}>{@render newItems()}</div>
</Modal>
{:else}
<Dropdown
size="small"
id="new-menu"
group="topbar"
variant="button"
align="right"
label="New"
>
{#snippet trigger()}
<Plus class="size-3.5" aria-hidden="true" />
<span class="hidden md:inline" aria-hidden="true">New</span>
{/snippet}
{@render newItems()}
</Dropdown>
{/if}
</div>
<div class={style.action({ class: "order-2 md:order-4" })}>
<NotificationBell />
</div>
<div class="order-3 md:hidden">
<Button
variant="ghost"
icon={Search}
size="small"
aria-label="Search"
aria-haspopup="dialog"
aria-expanded={searchSheetOpen}
onclick={() => (searchSheetOpen = true)}
/>
<Modal drawer class="h-full" bind:open={searchSheetOpen}>
{#if searchSheetOpen}
<TopbarSearch sheet onnavigate={() => (searchSheetOpen = false)} />
{/if}
</Modal>
</div>
<div class={style.divider({ class: "md:order-2" })}></div>
{/if}
<div class={style.action({ class: "order-4 md:order-5" })}>
{#if loading}
<div class={style.identity()} role="status" aria-label="Signing in">
<span class={style.ball()}>
<Spinner class="size-4" />
</span>
<span class={style.bar()} aria-hidden="true"></span>
</div>
{:else if user}
{#snippet aboutLinks()}
{#each siteLinkSections as section (section.title)}
<DropdownGroup label={section.title} icon={section.icon}>
{#each section.links as link (link.href)}
<DropdownItem
href={siteLinkHref(link)}
target={link.newTab ? "_blank" : undefined}
rel={link.internal
? undefined
: "external noopener noreferrer"}
>{link.label}{#if link.status}<StatusDot
/>{/if}</DropdownItem
>
{/each}
</DropdownGroup>
{/each}
{/snippet}
{#snippet profileItems(current: CurrentUser, about: Snippet)}
{@const actor = profileIdentifier(
isHandle(current.handle) ? current.handle : undefined,
current.did
)}
{#if others.length > 0}
<div class={style.switchHeader()}>Switch account</div>
{#each others as account (account.did)}
<DropdownItem onclick={() => onSwitchAccount?.(account.did)}>
<User did={account.did} handle={account.handle} />
</DropdownItem>
{/each}
{/if}
<DropdownItem data-testid="addAccount" href={appPath(loginHref)}>
<span class={style.addBall()}>
<Plus class="size-3" aria-hidden="true" />
</span>
Add account
</DropdownItem>
<Separator />
<DropdownItem href={appPath(`/${actor}`)} icon={UserIcon}
>Profile</DropdownItem
>
<DropdownItem href={appPath(`/${actor}?tab=repos`)} icon={BookMarked}>
Repositories
</DropdownItem>
<DropdownItem
href={appPath(`/${actor}?tab=strings`)}
icon={LineSquiggle}
>
Strings
</DropdownItem>
<DropdownItem href={resolve("/settings/organizations")} icon={Building2}
>Organizations</DropdownItem
>
<DropdownItem href={resolve("/settings")} icon={Cog}
>Settings</DropdownItem
>
{@render about()}
<DropdownItem danger icon={LogOut} onclick={onSignOut}
>Sign out</DropdownItem
>
{/snippet}
{#snippet aboutSubmenu()}
<DropdownSubmenu label="About" icon={Info}
>{@render aboutLinks()}</DropdownSubmenu
>
{/snippet}
{#snippet aboutDrillIn()}
<Button
variant="ghost"
icon={Info}
class={dropdownItem({ class: "[&>span]:w-full" })}
onclick={() => (aboutOpen = true)}
>
<span class="grow">About</span>
<ChevronRight class={buttonIcon()} aria-hidden="true" />
</Button>
{/snippet}
{#if mobile.current}
<Button
variant="ghost"
size="small"
class="w-7 px-0"
aria-label="Account"
aria-haspopup="dialog"
aria-expanded={profileSheetOpen}
onclick={openProfileSheet}
>
<User
did={user.did}
handle={user.handle}
handleClass="hidden"
loading="eager"
/>
</Button>
<Modal drawer bind:open={profileSheetOpen}>
<div
class={style.panes()}
style:height={paneHeight === undefined
? undefined
: `${paneHeight}px`}
>
{#if aboutOpen}
<div
class={style.sheet({ class: style.pane() })}
transition:fly={slide("100%")}
bind:offsetHeight={aboutHeight}
>
<Button
variant="ghost"
icon={ChevronLeft}
class={dropdownItem()}
onclick={() => (aboutOpen = false)}>About</Button
>
{@render aboutLinks()}
</div>
{:else}
<div
class={style.sheet({ class: style.pane() })}
transition:fly={slide("-100%")}
bind:offsetHeight={mainHeight}
>
{@render profileItems(user, aboutDrillIn)}
</div>
{/if}
</div>
</Modal>
{:else}
<Dropdown
id="profile-menu"
group="topbar"
align="right"
buttonVariant="ghost"
triggerClass="max-md:w-7 max-md:px-0 md:pl-2xs"
variant="button"
size="small"
>
{#snippet trigger()}
<User
did={user.did}
handle={user.handle}
handleClass="hidden md:inline"
loading="eager"
/>
{/snippet}
{@render profileItems(user, aboutSubmenu)}
</Dropdown>
{/if}
{:else}
<div class={style.identity({ class: "gap-xs" })}>
<Button
data-testid="login"
variant="ghost"
size="small"
href={appPath(loginHref)}>Sign in</Button
>
<Button variant="primary" size="small" href={resolve("/signup")}>
Join now
</Button>
</div>
{/if}
</div>
{/if}
</div>
</div>
</nav>