Files
better-auth/docs/components/version-switcher.tsx

293 lines
9.4 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Check } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { DocsVersion } from "@/lib/docs-versions";
import {
docsVersions,
getVersionFromPathname,
stripVersionPrefix,
versionedDocsHref,
} from "@/lib/docs-versions";
export function VersionSwitcher({ className }: { className?: string }) {
const pathname = usePathname() || "/docs";
const router = useRouter();
const [open, setOpen] = useState(false);
const timeout = useRef<NodeJS.Timeout>(undefined);
const containerRef = useRef<HTMLDivElement>(null);
const currentVersion = getVersionFromPathname(pathname);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", onClickOutside);
}
return () => document.removeEventListener("mousedown", onClickOutside);
}, [open]);
function handleSelect(version: DocsVersion) {
setOpen(false);
if (version.slug === currentVersion.slug) return;
const pagePath = stripVersionPrefix(pathname, currentVersion);
const targetHref = versionedDocsHref(pagePath, version);
router.push(targetHref);
}
const displayLabel = currentVersion.label;
return (
<div ref={containerRef} className={`relative ${className ?? ""}`}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
onMouseEnter={() => {
clearTimeout(timeout.current);
setOpen(true);
}}
onMouseLeave={() => {
timeout.current = setTimeout(() => setOpen(false), 150);
}}
className="flex items-center gap-1.5 px-3 py-1.5 h-full transition-colors duration-150 hover:bg-foreground/[0.04]"
>
<span className="font-mono text-[10px] uppercase tracking-wider text-foreground/65 dark:text-foreground/50 whitespace-nowrap">
{displayLabel}
</span>
{currentVersion.badge && (
<span className="font-mono text-[9px] uppercase tracking-wider px-1 py-0.5 border border-dashed border-foreground/25 text-foreground/55">
{currentVersion.badge}
</span>
)}
<svg
className={`h-2 w-2 text-foreground/55 dark:text-foreground/40 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
viewBox="0 0 10 6"
fill="none"
>
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.2" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12, ease: "easeOut" }}
onMouseEnter={() => {
clearTimeout(timeout.current);
}}
onMouseLeave={() => {
timeout.current = setTimeout(() => setOpen(false), 150);
}}
className="absolute top-full right-0 z-50 min-w-[160px] border border-foreground/[0.08] bg-background shadow-2xl shadow-black/20 dark:shadow-black/60 py-1"
>
{docsVersions.map((version, i) => {
const isActive = version.slug === currentVersion.slug;
return (
<motion.button
key={version.version}
type="button"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.1,
delay: i * 0.02,
}}
onClick={() => handleSelect(version)}
className="flex w-full items-center gap-2 px-3 py-1.5 hover:bg-foreground/[0.06] transition-colors duration-150 cursor-pointer"
>
<span className="w-3.5 shrink-0">
{isActive && (
<Check className="h-3 w-3 text-foreground/70" />
)}
</span>
<span className="font-mono text-[10px] uppercase tracking-wider text-foreground/75 dark:text-foreground/60 whitespace-nowrap">
{version.label}
</span>
{version.badge && (
<span className="font-mono text-[9px] uppercase tracking-wider px-1 py-0.5 border border-dashed border-foreground/25 text-foreground/55">
{version.badge}
</span>
)}
</motion.button>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/** Simplified version for mobile — just a row of buttons. */
export function MobileVersionSwitcher() {
const pathname = usePathname() || "/docs";
const router = useRouter();
const currentVersion = getVersionFromPathname(pathname);
function handleSelect(version: DocsVersion) {
if (version.slug === currentVersion.slug) return;
const pagePath = stripVersionPrefix(pathname, currentVersion);
router.push(versionedDocsHref(pagePath, version));
}
return (
<div className="flex items-center gap-1 px-2">
{docsVersions.map((version) => {
const isActive = version.slug === currentVersion.slug;
return (
<button
key={version.version}
type="button"
onClick={() => handleSelect(version)}
className={`flex items-center gap-1 px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors duration-150 ${
isActive
? "text-foreground bg-foreground/10"
: "text-foreground/50 hover:text-foreground/80 hover:bg-foreground/5"
}`}
>
{version.label}
{version.badge && (
<span className="text-[9px] px-1 py-0.5 border border-dashed border-foreground/25 text-foreground/55">
{version.badge}
</span>
)}
</button>
);
})}
</div>
);
}
/** Version switcher for the docs sidebar — full-width popover dropdown below the search bar. */
export function SidebarVersionSwitcher() {
const pathname = usePathname() || "/docs";
const router = useRouter();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const currentVersion = getVersionFromPathname(pathname);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", onClickOutside);
}
return () => document.removeEventListener("mousedown", onClickOutside);
}, [open]);
function handleSelect(version: DocsVersion) {
setOpen(false);
if (version.slug === currentVersion.slug) return;
const pagePath = stripVersionPrefix(pathname, currentVersion);
router.push(versionedDocsHref(pagePath, version));
}
return (
<div ref={containerRef} className="relative border-y border-foreground/5">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="group/version flex w-full items-center gap-2 px-4 py-[9px] text-sm text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3 transition-colors"
>
<svg
className="size-4 shrink-0 text-foreground opacity-55 group-hover/version:opacity-80 transition-opacity"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7 8.25a2.75 2.75 0 1 0 0-5.5a2.75 2.75 0 0 0 0 5.5m0 0V12m0 3.75a2.75 2.75 0 1 0 0 5.5a2.75 2.75 0 0 0 0-5.5m0 0V12m10-3.75a2.75 2.75 0 1 0 0-5.5a2.75 2.75 0 0 0 0 5.5m0 0V9a3 3 0 0 1-3 3H7" />
</svg>
<span className="truncate">{currentVersion.label}</span>
{currentVersion.badge && (
<span className="font-mono text-[9px] uppercase tracking-wider px-1.5 py-0.5 border border-dashed border-foreground/20 text-foreground/45">
{currentVersion.badge}
</span>
)}
{/* Up/down chevron */}
<svg
className="ml-auto size-4 shrink-0 text-foreground/40"
viewBox="0 0 16 16"
fill="none"
>
<path
d="M5 6.5L8 3.5L11 6.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 9.5L8 12.5L11 9.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className="absolute left-0 right-0 top-full z-50 border-b border-foreground/[0.08] bg-background shadow-lg shadow-black/10 dark:shadow-black/40 py-1"
>
{docsVersions.map((version) => {
const isActive = version.slug === currentVersion.slug;
return (
<button
key={version.version}
type="button"
onClick={() => handleSelect(version)}
className={`flex w-full items-center gap-2 px-4 py-2 text-sm transition-colors duration-150 ${
isActive
? "text-foreground bg-foreground/5"
: "text-foreground/55 hover:text-foreground/80 hover:bg-foreground/3"
}`}
>
<span className="size-4 shrink-0 flex items-center justify-center">
{isActive && (
<Check className="size-3.5 text-foreground/70" />
)}
</span>
<span className="truncate">{version.label}</span>
{version.badge && (
<span className="font-mono text-[9px] uppercase tracking-wider px-1.5 py-0.5 border border-dashed border-foreground/20 text-foreground/45">
{version.badge}
</span>
)}
</button>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}