Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410<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 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-8" /> </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" href={appPath("/search")} icon={Search} size="small" aria-hidden="true" aria-label="Search" /> </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>