Template
Add theme toggle, hero snippet, and reorganize homepage components
- Set up next-themes with a light/dark toggle in the header and move all sections onto theme tokens; dark mode stays pure black - Replace the hero terminal line with the AI Elements Snippet component - Restructure components into layout/, sections/, and shared/ with site-wide config in lib/site.ts Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
a40edbffa9
commit
07a99b441c
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { RiCloseLine, RiMenuLine } from '@remixicon/react';
|
||||
|
||||
import { Brand } from '@/components/shared/brand';
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { siteConfig, type NavigationItem } from '@/lib/site';
|
||||
|
||||
export function MobileNavigation({ items }: { items: NavigationItem[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigating = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const desktop = window.matchMedia('(min-width: 768px)');
|
||||
const closeOnDesktop = () => {
|
||||
if (desktop.matches) setOpen(false);
|
||||
};
|
||||
|
||||
desktop.addEventListener('change', closeOnDesktop);
|
||||
return () => desktop.removeEventListener('change', closeOnDesktop);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open navigation menu"
|
||||
className="inline-flex size-10 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30 md:hidden"
|
||||
>
|
||||
<RiMenuLine className="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent
|
||||
side="top"
|
||||
showCloseButton={false}
|
||||
overlayClassName="z-[60] bg-black/45 duration-300 supports-backdrop-filter:backdrop-blur-md md:hidden"
|
||||
className="z-[61] max-h-dvh w-full gap-0 overflow-hidden rounded-none border-border bg-background text-foreground outline-none duration-300 data-[side=top]:data-open:slide-in-from-top data-[side=top]:data-closed:slide-out-to-top md:hidden"
|
||||
onCloseAutoFocus={(event) => {
|
||||
// Let the anchor move focus to its section without the trigger
|
||||
// interrupting the page's smooth scroll when the sheet closes.
|
||||
if (navigating.current) event.preventDefault();
|
||||
navigating.current = false;
|
||||
}}
|
||||
>
|
||||
<div className="flex h-16 shrink-0 items-center justify-between border-b border-border px-6">
|
||||
<Brand />
|
||||
<SheetClose asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation menu"
|
||||
className="inline-flex size-10 items-center justify-center rounded-md text-foreground/60 transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30"
|
||||
>
|
||||
<RiCloseLine className="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-3">
|
||||
<SheetTitle className="px-3 pb-2 pt-3 text-[10px] font-medium uppercase tracking-[0.2em] text-foreground/40">
|
||||
Explore {siteConfig.name}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Explore our products, solutions, resources, documentation, and pricing.
|
||||
</SheetDescription>
|
||||
<nav aria-label="Mobile navigation">
|
||||
<ul className="space-y-1">
|
||||
{items.map(({ label, href, description }) => (
|
||||
<li key={label}>
|
||||
<SheetClose asChild>
|
||||
<a
|
||||
href={href}
|
||||
onClick={() => {
|
||||
navigating.current = true;
|
||||
}}
|
||||
className="flex items-center rounded-xl px-3 py-3 transition-colors hover:bg-foreground/5 focus-visible:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/30"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xl font-medium tracking-tight">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-foreground/45">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</SheetClose>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 space-y-2 border-t border-border bg-foreground/[0.02] p-5">
|
||||
<SheetClose asChild>
|
||||
<a
|
||||
href={siteConfig.links.signup}
|
||||
onClick={() => {
|
||||
navigating.current = true;
|
||||
}}
|
||||
className="flex h-12 items-center justify-center rounded-full bg-foreground px-4 text-sm font-medium text-background transition-colors hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
>
|
||||
Sign Up
|
||||
</a>
|
||||
</SheetClose>
|
||||
<SheetClose asChild>
|
||||
<a
|
||||
href={siteConfig.links.login}
|
||||
onClick={() => {
|
||||
navigating.current = true;
|
||||
}}
|
||||
className="flex h-12 items-center justify-center rounded-full border border-border px-4 text-sm font-medium text-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/30"
|
||||
>
|
||||
Log In
|
||||
</a>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { RiGithubFill, RiTwitterXFill } from '@remixicon/react';
|
||||
|
||||
import { Brand } from '@/components/shared/brand';
|
||||
import { Container } from '@/components/shared/container';
|
||||
import { footerColumns, siteConfig } from '@/lib/site';
|
||||
|
||||
const socialLinks = [
|
||||
{ label: 'GitHub', href: siteConfig.links.github, Icon: RiGithubFill },
|
||||
{ label: 'X (Twitter)', href: siteConfig.links.twitter, Icon: RiTwitterXFill },
|
||||
];
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border">
|
||||
<Container className="py-16">
|
||||
<div className="grid gap-10 md:grid-cols-[1.5fr_repeat(4,1fr)]">
|
||||
<div>
|
||||
<Brand />
|
||||
<p className="mt-4 max-w-xs text-sm text-foreground/45">
|
||||
{siteConfig.tagline}
|
||||
</p>
|
||||
</div>
|
||||
{footerColumns.map((column) => (
|
||||
<div key={column.heading}>
|
||||
<h4 className="text-sm font-medium text-foreground/80">
|
||||
{column.heading}
|
||||
</h4>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{column.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<a
|
||||
href={link.href}
|
||||
className="text-sm text-foreground/45 transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-14 flex flex-col items-start justify-between gap-4 border-t border-border pt-8 text-sm text-foreground/40 sm:flex-row sm:items-center">
|
||||
<span>
|
||||
© {new Date().getFullYear()} {siteConfig.name}, Inc. All rights reserved.
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{socialLinks.map(({ label, href, Icon }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={href}
|
||||
aria-label={label}
|
||||
className="inline-flex size-8 items-center justify-center rounded-md text-foreground/45 transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
>
|
||||
<Icon className="size-4" aria-hidden="true" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MobileNavigation } from '@/components/layout/mobile-navigation';
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle';
|
||||
import { Brand } from '@/components/shared/brand';
|
||||
import { Container } from '@/components/shared/container';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { navigationItems, siteConfig } from '@/lib/site';
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/70 backdrop-blur-xl">
|
||||
<Container className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-8">
|
||||
<a href="#top" aria-label={`${siteConfig.name} home`}>
|
||||
<Brand />
|
||||
</a>
|
||||
<nav aria-label="Main navigation" className="hidden md:block">
|
||||
<ul className="flex items-center gap-6">
|
||||
{navigationItems.map(({ label, href }) => (
|
||||
<li key={label}>
|
||||
<a
|
||||
href={href}
|
||||
className="text-sm text-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="hidden text-foreground/70 hover:bg-foreground/5 hover:text-foreground sm:inline-flex"
|
||||
>
|
||||
<a href={siteConfig.links.login}>Log In</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="hidden rounded-full bg-foreground text-background hover:bg-foreground/90 sm:inline-flex"
|
||||
>
|
||||
<a href={siteConfig.links.signup}>Sign Up</a>
|
||||
</Button>
|
||||
<MobileNavigation items={navigationItems} />
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { RiMoonLine, RiSunLine } from '@remixicon/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const emptySubscribe = () => () => {};
|
||||
|
||||
export function ThemeToggle() {
|
||||
const mounted = useSyncExternalStore(
|
||||
emptySubscribe,
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
const isDark = mounted && resolvedTheme === 'dark';
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
aria-label={
|
||||
mounted ? `Switch to ${isDark ? 'light' : 'dark'} theme` : 'Toggle theme'
|
||||
}
|
||||
className="text-foreground/70 hover:bg-foreground/5 hover:text-foreground"
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
>
|
||||
{mounted ? (
|
||||
isDark ? (
|
||||
<RiSunLine className="size-4" />
|
||||
) : (
|
||||
<RiMoonLine className="size-4" />
|
||||
)
|
||||
) : (
|
||||
<span className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user