diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index f251200d..baafe33a 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,7 +1,8 @@ "use client"; import { useState, useEffect, type ElementType } from "react"; import Link from "next/link"; -import { Github, Menu, X } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { Github, Menu, Star, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Logo } from "./logo"; import { SOURCE_URL } from "./footer"; @@ -41,9 +42,83 @@ const menuItemVariants = { }), }; +type NavLink = { + href: string; + label: string; + icon?: ElementType; + external?: boolean; +}; + +const NAV_LINKS: NavLink[] = [ + { href: "/", label: "Home" }, + { href: "/features", label: "Features" }, + { href: "/developer-tools", label: "Platform" }, + { href: "/download", label: "Download" }, + { href: "/tools", label: "Tools" }, + { href: "/changelog", label: "Changelog" }, +]; + +// "/" only matches exactly; every other section also owns its sub-routes +// (e.g. /tools/json-formatter keeps "Tools" highlighted). +function isActive(pathname: string, href: string) { + if (href === "/") return pathname === "/"; + return pathname === href || pathname.startsWith(`${href}/`); +} + +const STARS_API = SOURCE_URL.replace( + "https://github.com/", + "https://api.github.com/repos/" +); +const STARS_CACHE_KEY = "mdt:gh-stars"; + +function formatStars(n: number) { + return n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : `${n}`; +} + +// Unauthenticated GitHub API: 60 req/hour per visitor IP. Cached per session so +// client-side navigation between pages doesn't refetch. Any failure (offline, +// rate limited) just leaves the count hidden. +// ponytail: client fetch — Header isn't in a shared layout, so there's no server +// component to thread a prop from. Move it server-side if the layout ever unifies. +function useGithubStars() { + const [stars, setStars] = useState(null); + + useEffect(() => { + try { + const cached = sessionStorage.getItem(STARS_CACHE_KEY); + if (cached) { + setStars(Number(cached)); + return; + } + } catch { + // storage blocked — fall through and fetch + } + + const controller = new AbortController(); + fetch(STARS_API, { + headers: { Accept: "application/vnd.github+json" }, + signal: controller.signal, + }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const count = data?.stargazers_count; + if (typeof count !== "number") return; + setStars(count); + sessionStorage.setItem(STARS_CACHE_KEY, String(count)); + }) + .catch(() => {}); + + return () => controller.abort(); + }, []); + + return stars; +} + export function Header() { const [isMenuOpen, setIsMenuOpen] = useState(false); const [scrolled, setScrolled] = useState(false); + const pathname = usePathname() ?? "/"; + const stars = useGithubStars(); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8); @@ -52,18 +127,8 @@ export function Header() { return () => window.removeEventListener("scroll", onScroll); }, []); - const mobileNavLinks: { - href: string; - label: string; - icon?: ElementType; - external?: boolean; - }[] = [ - { href: "/", label: "Home" }, - { href: "/features", label: "Features" }, - { href: "/download", label: "Download" }, - { href: "/developer-tools", label: "Platform" }, - { href: "/tools", label: "Tools" }, - { href: "/changelog", label: "Changelog" }, + const mobileNavLinks: NavLink[] = [ + ...NAV_LINKS, { href: SOURCE_URL, label: "GitHub", icon: Github, external: true }, ]; @@ -86,42 +151,25 @@ export function Header() { {/* Desktop Navigation */}
@@ -130,17 +178,27 @@ export function Header() { @@ -205,7 +263,10 @@ export function Header() { >