Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 126 additions & 55 deletions apps/web/src/components/header.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<number | null>(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);
Expand All @@ -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 },
];

Expand All @@ -86,42 +151,25 @@ export function Header() {
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-8">
<nav className="flex items-center gap-8 text-md font-medium">
<Link
href="/"
className="relative py-2 transition-colors hover:text-foreground/80 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-foreground after:transition-all hover:after:w-full"
>
Home
</Link>
<Link
href="/features"
className="relative py-2 transition-colors hover:text-foreground/80 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-foreground after:transition-all hover:after:w-full"
>
Features
</Link>
<Link
href="/developer-tools"
className="relative py-2 transition-colors hover:text-foreground/80 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-foreground after:transition-all hover:after:w-full"
>
Platform
</Link>
<Link
href="/download"
className="relative py-2 transition-colors hover:text-foreground/80 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-foreground after:transition-all hover:after:w-full"
>
Download
</Link>
<Link
href="/tools"
className="relative py-2 transition-colors hover:text-foreground/80 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-foreground after:transition-all hover:after:w-full"
>
Tools
</Link>
<Link
href="/changelog"
className="relative py-2 transition-colors hover:text-foreground/80 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 after:bg-foreground after:transition-all hover:after:w-full"
>
Changelog
</Link>
{NAV_LINKS.map((link) => {
const active = isActive(pathname, link.href);
return (
<Link
key={link.href}
href={link.href}
aria-current={active ? "page" : undefined}
className={cn(
"relative py-2 transition-colors",
"after:absolute after:bottom-0 after:left-0 after:h-0.5 after:bg-foreground after:transition-all hover:after:w-full",
active
? "text-foreground after:w-full"
: "text-foreground/70 hover:text-foreground after:w-0"
)}
>
{link.label}
</Link>
);
})}
</nav>
</div>

Expand All @@ -130,17 +178,27 @@ export function Header() {
<Button
asChild
variant="ghost"
size="icon"
className="hidden md:inline-flex h-10 w-10"
size="sm"
className="hidden md:inline-flex h-10 gap-2 px-3"
>
<a
href={SOURCE_URL}
target="_blank"
rel="noreferrer"
aria-label="MyDevTools on GitHub"
aria-label={
stars === null
? "MyDevTools on GitHub"
: `MyDevTools on GitHub, ${stars} stars`
}
title="Star MyDevTools on GitHub"
>
<Github className="h-5 w-5" />
{stars !== null && (
<span className="flex items-center gap-1 text-sm font-medium tabular-nums">
<Star className="h-3.5 w-3.5 fill-current" />
{formatStars(stars)}
</span>
)}
</a>
</Button>

Expand Down Expand Up @@ -205,7 +263,10 @@ export function Header() {
>
<nav className="bg-background/95 backdrop-blur-xl border-t border-border/40 dark:border-white/5">
<div className="px-4 py-4 space-y-1">
{mobileNavLinks.map((link, index) => (
{mobileNavLinks.map((link, index) => {
const active =
!link.external && isActive(pathname, link.href);
return (
<motion.div
key={link.href}
custom={index}
Expand All @@ -217,19 +278,29 @@ export function Header() {
href={link.href}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noreferrer" : undefined}
aria-current={active ? "page" : undefined}
onClick={() => setIsMenuOpen(false)}
className={cn(
"flex items-center gap-3 px-4 py-3.5 rounded-xl",
"text-base font-medium transition-all duration-200",
"hover:bg-muted/60 active:scale-[0.98]",
"min-h-[48px]"
"min-h-[48px]",
active &&
"bg-muted text-foreground border-l-2 border-primary rounded-l-md"
)}
>
{link.icon && <link.icon className="h-5 w-5" />}
<span>{link.label}</span>
{link.external && stars !== null && (
<span className="ml-auto flex items-center gap-1 text-sm text-foreground/70 tabular-nums">
<Star className="h-3.5 w-3.5 fill-current" />
{formatStars(stars)}
</span>
)}
</Link>
</motion.div>
))}
);
})}
</div>
</nav>
</motion.div>
Expand Down
Loading