Vectrum
Sign In
148 lines · 6.1 KB · TypeScript
"use client";

import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ComponentType, SVGProps } from "react";
import { Avatar as MuAvatar, Menu, MenuItem, MenuSeparator, Sidebar } from "@unlocalhosted/metalui";
import { logoutAction } from "@/app/actions/auth";
import type { Theme } from "@/lib/theme";
import { BRAND, LogoMark } from "./brand";
import { ChevronsUpDownIcon, CompassIcon, GearIcon, HomeIcon, KeyIcon, MenuIcon, OrganizationIcon, PersonIcon, PlusIcon, RepoIcon, ShieldLockIcon, SignOutIcon } from "./icons";
import { ThemeToggle } from "./theme-toggle";
import { RepoAvatar } from "./ui";

type Item = { href: string; label: string; icon: ComponentType<SVGProps<SVGSVGElement>>; match: (p: string) => boolean };

export type SidebarRepo = { owner: string; name: string; imageVersion: number | null };
export type SidebarViewer = { username: string; name: string };

function navItems(username: string): Item[] {
  return [
    { href: "/", label: "Overview", icon: HomeIcon, match: (p) => p === "/" },
    { href: `/${username}`, label: "Repositories", icon: RepoIcon, match: (p) => p === `/${username}` },
    { href: "/explore", label: "Explore", icon: CompassIcon, match: (p) => p.startsWith("/explore") },
    { href: "/settings/profile", label: "Settings", icon: GearIcon, match: (p) => p.startsWith("/settings") },
  ];
}

/** The account's rows: who you are, then where your account lives, then Sign Out (last, on its own). */
function AccountMenuItems({ viewer }: { viewer: SidebarViewer }) {
  const router = useRouter();
  return (
    <>
      <MenuItem icon={<PersonIcon />} onSelect={() => router.push(`/${viewer.username}`)}>
        Your Profile
      </MenuItem>
      <MenuItem icon={<OrganizationIcon />} onSelect={() => router.push("/settings/organizations")}>
        Your Organizations
      </MenuItem>
      <MenuItem icon={<GearIcon />} onSelect={() => router.push("/settings/profile")}>
        Settings
      </MenuItem>
      <MenuItem icon={<KeyIcon />} onSelect={() => router.push("/settings/keys")}>
        SSH Keys
      </MenuItem>
      <MenuItem icon={<ShieldLockIcon />} onSelect={() => router.push("/settings/tokens")}>
        API Tokens
      </MenuItem>
      <MenuSeparator />
      <MenuItem icon={<SignOutIcon />} onSelect={() => void logoutAction()}>
        Sign Out
      </MenuItem>
    </>
  );
}

/** Left rail for signed-in users: MetalUI's Sidebar with the account menu, the places, and repository shortcuts. */
export function AppSidebar({ viewer, repos, theme }: { viewer: SidebarViewer; repos: SidebarRepo[]; theme: Theme }) {
  const pathname = usePathname();
  return (
    <aside className="sticky top-0 hidden h-screen shrink-0 lg:block">
      <Sidebar aria-label="Navigation">
        <Sidebar.Header>
          <Menu
            heading={`SIGNED IN AS @${viewer.username.toUpperCase()}`}
            align="start"
            trigger={
              <button
                type="button"
                aria-label="Open account menu"
                className="mu-icon-trigger flex h-11 w-full items-center gap-2.5 rounded-[12px] px-2 text-left outline-none transition-colors hover:recipe-row-list-hover focus-visible:focus-ring data-popup-open:recipe-row-list-hover"
              >
                <MuAvatar name={viewer.name || viewer.username} size="small" />
                <span className="min-w-0 flex-1">
                  <span className="block truncate type-title text-ink">{viewer.name || viewer.username}</span>
                  <span className="block truncate type-meta text-ink3">@{viewer.username}</span>
                </span>
                <ChevronsUpDownIcon width={14} height={14} className="text-ink3" />
              </button>
            }
          >
            <AccountMenuItems viewer={viewer} />
          </Menu>
        </Sidebar.Header>

        <Sidebar.Section title="Places">
          {navItems(viewer.username).map((item) => (
            <Sidebar.Item key={item.href} icon={<item.icon />} active={item.match(pathname)} render={<Link href={item.href} />}>
              {item.label}
            </Sidebar.Item>
          ))}
        </Sidebar.Section>

        <Sidebar.Section title={`Repositories · ${repos.length}`}>
          {repos.slice(0, 12).map((r) => {
            const base = `/${r.owner}/${r.name}`;
            const active = pathname === base || pathname.startsWith(`${base}/`);
            return (
              <Sidebar.Item key={base} icon={<RepoAvatar repo={r} size={16} />} active={active} render={<Link href={base} />}>
                {r.owner !== viewer.username ? `${r.owner}/${r.name}` : r.name}
              </Sidebar.Item>
            );
          })}
          <Sidebar.Item icon={<PlusIcon />} active={pathname === "/new"} render={<Link href="/new" />}>
            New Repository
          </Sidebar.Item>
        </Sidebar.Section>

        <Sidebar.Footer>
          <div className="flex flex-col gap-3 px-1.5 pb-1">
            <ThemeToggle initial={theme} />
            <Link href="/" aria-label="Home" className="flex items-center gap-2 text-ink3 transition-colors hover:text-ink">
              <LogoMark size={16} />
              <span className="type-label engraved">{BRAND.name}</span>
            </Link>
          </div>
        </Sidebar.Footer>
      </Sidebar>
    </aside>
  );
}

/** The same places as a menu, for small screens. */
export function MobileNav({ viewer }: { viewer: SidebarViewer }) {
  const router = useRouter();
  return (
    <Menu
      heading="GO TO"
      align="start"
      trigger={
        <button
          type="button"
          aria-label="Open navigation"
          className="mu-icon-trigger material-cap flex size-8 items-center justify-center rounded-full text-ink2 outline-none transition-colors hover:text-ink focus-visible:focus-ring"
        >
          <MenuIcon width={15} height={15} />
        </button>
      }
    >
      {navItems(viewer.username).map((item) => (
        <MenuItem key={item.href} icon={<item.icon />} onSelect={() => router.push(item.href)}>
          {item.label}
        </MenuItem>
      ))}
      <MenuSeparator />
      <AccountMenuItems viewer={viewer} />
    </Menu>
  );
}