feat(web): app shell with responsive nav + theme provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c9960aae24
commit
63d41c4389
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
134
apps/web/src/components/app-shell.tsx
Normal file
134
apps/web/src/components/app-shell.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
apps/web/src/components/nav-config.ts
Normal file
16
apps/web/src/components/nav-config.ts
Normal 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 },
|
||||
];
|
||||
20
apps/web/src/components/theme-provider.tsx
Normal file
20
apps/web/src/components/theme-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user