feat(web): app shell with responsive nav + theme provider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-09 23:09:33 +08:00
parent c9960aae24
commit 63d41c4389
4 changed files with 181 additions and 11 deletions

View File

@ -1,20 +1,15 @@
import type { Metadata, Viewport } from "next";
import { GeistSans } from "geist/font/sans";
import { ThemeProvider } from "@/components/theme-provider";
import { AppShell } from "@/components/app-shell";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: "cm WhatsApp Bot",
description: "Self-hosted WhatsApp reminder bot",
applicationName: "cm WhatsApp Bot",
appleWebApp: {
capable: true,
title: "cm WA Bot",
statusBarStyle: "default",
},
appleWebApp: { capable: true, title: "cm WA Bot", statusBarStyle: "default" },
};
export const viewport: Viewport = {
@ -26,8 +21,13 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={cn(GeistSans.className, "font-sans", geist.variable)}>
<body>{children}</body>
<html lang="en" suppressHydrationWarning className={GeistSans.className}>
<body>
<ThemeProvider>
<AppShell>{children}</AppShell>
<Toaster richColors position="top-right" />
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,134 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { NAV_ITEMS } from "@/components/nav-config";
// ---------------------------------------------------------------------------
// Bottom nav (mobile only — hidden sm+)
// ---------------------------------------------------------------------------
function BottomNav() {
const pathname = usePathname();
return (
<nav
aria-label="Primary navigation"
className="fixed bottom-0 left-0 right-0 z-40 flex h-16 items-stretch border-t border-border bg-background sm:hidden"
>
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={key}
href={href}
aria-current={active ? "page" : undefined}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-0.5 min-h-[44px] text-xs font-medium transition-colors",
active
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<span
className={cn(
"flex items-center justify-center rounded-lg p-1.5 transition-colors",
active ? "bg-accent text-accent-foreground" : "",
)}
>
<Icon size={20} strokeWidth={active ? 2.5 : 1.75} aria-hidden />
</span>
<span className="leading-none">{label}</span>
</Link>
);
})}
</nav>
);
}
// ---------------------------------------------------------------------------
// Sidebar (desktop only — hidden below sm)
// ---------------------------------------------------------------------------
function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden sm:flex fixed left-0 top-0 bottom-0 z-40 w-56 flex-col border-r border-border bg-sidebar">
{/* Bot name / brand */}
<div className="flex h-14 items-center px-4 border-b border-sidebar-border shrink-0">
<span className="text-sm font-semibold tracking-tight text-sidebar-foreground">
cm WhatsApp Bot
</span>
</div>
{/* Nav items */}
<nav aria-label="Primary navigation" className="flex flex-col gap-1 p-3 flex-1">
{NAV_ITEMS.map(({ key, href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={key}
href={href}
aria-current={active ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors min-h-[44px]",
active
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
)}
>
<Icon size={18} strokeWidth={active ? 2.5 : 1.75} aria-hidden />
{label}
</Link>
);
})}
</nav>
</aside>
);
}
// ---------------------------------------------------------------------------
// Top app bar (mobile only)
// ---------------------------------------------------------------------------
function TopAppBar() {
const pathname = usePathname();
const currentItem = NAV_ITEMS.find(({ href }) =>
href === "/" ? pathname === "/" : pathname.startsWith(href),
);
const title = currentItem?.label ?? "cm WhatsApp Bot";
return (
<header className="fixed top-0 left-0 right-0 z-40 flex h-14 items-center border-b border-border bg-background px-4 sm:hidden">
<span className="text-base font-semibold tracking-tight">{title}</span>
</header>
);
}
// ---------------------------------------------------------------------------
// AppShell — the outer container
// ---------------------------------------------------------------------------
interface AppShellProps {
children: React.ReactNode;
}
export function AppShell({ children }: AppShellProps) {
return (
<>
{/* Desktop sidebar */}
<Sidebar />
{/* Mobile top app bar */}
<TopAppBar />
{/* Main content
Mobile: push down for top bar (pt-14), push up for bottom nav (pb-16)
Desktop: push right for sidebar (sm:pl-56), no top/bottom chrome offset */}
<main className="min-h-dvh pt-14 pb-16 sm:pl-56 sm:pt-0 sm:pb-0">
{children}
</main>
{/* Mobile bottom nav */}
<BottomNav />
</>
);
}

View File

@ -0,0 +1,16 @@
import { Home, Smartphone, Calendar, Settings } from "lucide-react";
import type { LucideIcon } from "lucide-react";
export interface NavItem {
key: string;
href: string;
label: string;
icon: LucideIcon;
}
export const NAV_ITEMS: NavItem[] = [
{ key: "dashboard", href: "/", label: "Dashboard", icon: Home },
{ key: "accounts", href: "/accounts", label: "Accounts", icon: Smartphone },
{ key: "reminders", href: "/reminders", label: "Reminders", icon: Calendar },
{ key: "settings", href: "/settings", label: "Settings", icon: Settings },
];

View File

@ -0,0 +1,20 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ComponentProps } from "react";
type ThemeProviderProps = ComponentProps<typeof NextThemesProvider>;
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
{children}
</NextThemesProvider>
);
}