mirror of
https://github.com/tale/headplane.git
synced 2026-10-26 22:47:55 +00:00
170 lines
5.9 KiB
TypeScript
170 lines
5.9 KiB
TypeScript
import { Dialog } from "@base-ui/react/dialog";
|
|
import { useSearchContext } from "fumadocs-ui/contexts/search";
|
|
import { Monitor, Moon, Search, Sun } from "lucide-react";
|
|
import { useTheme } from "next-themes";
|
|
import { Fragment, type ReactNode, useSyncExternalStore } from "react";
|
|
import { Link, useLocation } from "react-router";
|
|
|
|
import LogoMark from "@/components/logo-mark";
|
|
import VersionPicker from "@/components/version-picker";
|
|
|
|
export const iconButtonClass =
|
|
"text-muted hover:bg-hover hover:text-fg inline-flex size-8 shrink-0 items-center justify-center rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent [&_svg]:size-4";
|
|
|
|
const themeOptionClass =
|
|
"focus-visible:outline-accent inline-flex size-6 items-center justify-center rounded-[4px] focus-visible:outline-2 [&_svg]:size-3.5";
|
|
|
|
const themeList = [
|
|
{ value: "light", label: "Light", Icon: Sun },
|
|
{ value: "dark", label: "Dark", Icon: Moon },
|
|
{ value: "system", label: "System", Icon: Monitor },
|
|
];
|
|
|
|
const links = [
|
|
{ to: "/introduction", label: "Docs" },
|
|
{ to: "/changelog", label: "Changelog" },
|
|
];
|
|
|
|
export default function Header({ menu }: { menu?: ReactNode }) {
|
|
const { pathname } = useLocation();
|
|
const isChangelog = pathname.startsWith("/changelog");
|
|
|
|
return (
|
|
<header className="bg-surface border-line sticky top-[var(--fd-banner-height,0px)] z-30 h-13 border-b [contain:inline-size] [grid-area:header]">
|
|
<div className="flex h-full items-center gap-x-5 px-4 md:px-6">
|
|
<Link className="flex items-center gap-x-2" to="/">
|
|
<LogoMark className="size-[18px] shrink-0" />
|
|
<span className="text-sm font-semibold tracking-tight">headplane</span>
|
|
</Link>
|
|
<nav className="hidden items-center gap-x-0.5 text-[13px] font-medium md:flex">
|
|
{links.map((link) => {
|
|
const isActive =
|
|
link.to === "/changelog" ? isChangelog : pathname !== "/" && !isChangelog;
|
|
|
|
return (
|
|
<Link
|
|
className={
|
|
isActive
|
|
? "text-fg rounded-md px-2.5 py-1.5"
|
|
: "text-muted hover:text-fg rounded-md px-2.5 py-1.5"
|
|
}
|
|
key={link.to}
|
|
to={link.to}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
<div className="ml-auto flex items-center gap-x-1">
|
|
<SearchButton />
|
|
<VersionPicker className="max-sm:hidden" />
|
|
<a
|
|
aria-label="GitHub"
|
|
className={iconButtonClass}
|
|
href="https://github.com/tale/headplane"
|
|
>
|
|
<GitHubIcon />
|
|
</a>
|
|
<ThemePicker />
|
|
{menu}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function ThemePicker() {
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
// The stored choice only exists in the browser, so prerendered HTML shows none selected.
|
|
const isMounted = useSyncExternalStore(
|
|
() => () => {},
|
|
() => true,
|
|
() => false,
|
|
);
|
|
|
|
// Until a choice is stored, the theme follows the system, the last option.
|
|
const storedIndex = isMounted ? themeList.findIndex((item) => item.value === theme) : -1;
|
|
const currentIndex = storedIndex === -1 ? themeList.length - 1 : storedIndex;
|
|
const current = themeList[currentIndex];
|
|
const next = themeList[(currentIndex + 1) % themeList.length];
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
aria-label={`Theme: ${current.label}`}
|
|
className={`${iconButtonClass} sm:hidden`}
|
|
onClick={() => setTheme(next.value)}
|
|
title={`Theme: ${current.label}`}
|
|
type="button"
|
|
>
|
|
<current.Icon />
|
|
</button>
|
|
<div
|
|
aria-label="Theme"
|
|
className="border-line hidden h-8 items-center gap-0.5 rounded-md border p-0.5 sm:flex"
|
|
role="radiogroup"
|
|
>
|
|
{themeList.map(({ value, label, Icon }) => {
|
|
const isActive = isMounted && theme === value;
|
|
|
|
return (
|
|
<button
|
|
aria-checked={isActive}
|
|
aria-label={label}
|
|
className={`${themeOptionClass} ${isActive ? "bg-hover text-fg" : "text-muted hover:text-fg"}`}
|
|
key={value}
|
|
onClick={() => setTheme(value)}
|
|
role="radio"
|
|
title={label}
|
|
type="button"
|
|
>
|
|
<Icon />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SearchButton() {
|
|
const { enabled, hotKey, dialogHandle } = useSearchContext();
|
|
if (!enabled) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog.Trigger
|
|
aria-label="Search"
|
|
className={`${iconButtonClass} md:hidden`}
|
|
handle={dialogHandle}
|
|
>
|
|
<Search />
|
|
</Dialog.Trigger>
|
|
<Dialog.Trigger
|
|
className="border-line-strong bg-surface text-muted hover:text-fg mr-2 hidden h-8 w-60 items-center gap-2 rounded-md border px-2.5 text-sm transition-colors md:inline-flex"
|
|
handle={dialogHandle}
|
|
>
|
|
<Search className="size-4" />
|
|
Search docs
|
|
<kbd className="border-line text-faint ml-auto rounded border px-1 font-sans text-xs">
|
|
{hotKey.map((key, index) => (
|
|
<Fragment key={index}>{key.display}</Fragment>
|
|
))}
|
|
</kbd>
|
|
</Dialog.Trigger>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function GitHubIcon() {
|
|
return (
|
|
<svg aria-hidden fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.28-1.69-1.28-1.69-1.05-.72.08-.7.08-.7 1.16.08 1.77 1.19 1.77 1.19 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.56-.29-5.25-1.28-5.25-5.69 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.77 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.84 1.19 3.1 0 4.42-2.7 5.39-5.26 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.67.8.56A11.5 11.5 0 0 0 12 .5Z" />
|
|
</svg>
|
|
);
|
|
}
|