Add app shell layout with sidebar, topbar, breadcrumbs, theme toggle

- Collapsible sidebar (256px / 64px) with nav items and tooltips
- Topbar with breadcrumbs and notification bell placeholder
- Theme toggle (light/dark/system) via dropdown
- Zustand store for sidebar collapsed state
- Placeholder pages for all main routes (dashboard, projects,
  my-work, notifications, settings)
- Authenticated (app) layout group wrapping all protected routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Leivur R. Djurhuus 2026-02-28 21:09:07 -06:00
parent b4ae910cf5
commit afa98282ff
11 changed files with 345 additions and 0 deletions

View file

@ -0,0 +1,10 @@
export default function DashboardPage() {
return (
<div>
<h1 className="font-heading text-2xl font-bold">Dashboard</h1>
<p className="mt-2 text-[var(--muted-foreground)]">
Production pipeline overview KPIs and charts coming in Phase 3.
</p>
</div>
);
}

14
src/app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,14 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Topbar />
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}

View file

@ -0,0 +1,10 @@
export default function MyWorkPage() {
return (
<div>
<h1 className="font-heading text-2xl font-bold">My Work</h1>
<p className="mt-2 text-[var(--muted-foreground)]">
Your assigned stages and tasks coming in Phase 2.
</p>
</div>
);
}

View file

@ -0,0 +1,10 @@
export default function NotificationsPage() {
return (
<div>
<h1 className="font-heading text-2xl font-bold">Notifications</h1>
<p className="mt-2 text-[var(--muted-foreground)]">
Notification history coming in Phase 3.
</p>
</div>
);
}

View file

@ -0,0 +1,10 @@
export default function ProjectsPage() {
return (
<div>
<h1 className="font-heading text-2xl font-bold">Projects</h1>
<p className="mt-2 text-[var(--muted-foreground)]">
Project list and management CRUD coming next.
</p>
</div>
);
}

View file

@ -0,0 +1,10 @@
export default function SettingsPage() {
return (
<div>
<h1 className="font-heading text-2xl font-bold">Settings</h1>
<p className="mt-2 text-[var(--muted-foreground)]">
User and organization settings coming in Phase 4.
</p>
</div>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight, Home } from "lucide-react";
import { Fragment } from "react";
const LABEL_MAP: Record<string, string> = {
dashboard: "Dashboard",
projects: "Projects",
"my-work": "My Work",
notifications: "Notifications",
settings: "Settings",
table: "Table View",
board: "Board View",
timeline: "Timeline View",
deliverables: "Deliverables",
};
export function Breadcrumbs() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
if (segments.length === 0) return null;
const crumbs = segments.map((segment, index) => {
const href = "/" + segments.slice(0, index + 1).join("/");
const label = LABEL_MAP[segment] || decodeURIComponent(segment);
const isLast = index === segments.length - 1;
return { href, label, isLast };
});
return (
<nav className="flex items-center gap-1 text-sm">
<Link
href="/dashboard"
className="text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
>
<Home className="h-4 w-4" />
</Link>
{crumbs.map((crumb) => (
<Fragment key={crumb.href}>
<ChevronRight className="h-3 w-3 text-[var(--muted-foreground)]" />
{crumb.isLast ? (
<span className="font-medium text-[var(--foreground)]">
{crumb.label}
</span>
) : (
<Link
href={crumb.href}
className="text-[var(--muted-foreground)] transition-colors hover:text-[var(--foreground)]"
>
{crumb.label}
</Link>
)}
</Fragment>
))}
</nav>
);
}

View file

@ -0,0 +1,137 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
FolderKanban,
ClipboardList,
Bell,
Settings,
PanelLeftClose,
PanelLeft,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSidebarStore } from "@/stores/sidebar-store";
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/projects", label: "Projects", icon: FolderKanban },
{ href: "/my-work", label: "My Work", icon: ClipboardList },
{ href: "/notifications", label: "Notifications", icon: Bell },
];
const bottomItems = [
{ href: "/settings", label: "Settings", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const { isCollapsed, toggle } = useSidebarStore();
return (
<aside
className={cn(
"flex h-screen flex-col border-r bg-[var(--muted)] transition-all duration-200",
isCollapsed ? "w-16" : "w-64"
)}
>
{/* Logo / Title */}
<div className="flex h-14 items-center gap-2 border-b px-4">
{!isCollapsed && (
<span className="font-heading text-sm font-bold tracking-tight">
HP Tracker
</span>
)}
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", isCollapsed ? "mx-auto" : "ml-auto")}
onClick={toggle}
>
{isCollapsed ? (
<PanelLeft className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
</div>
{/* Main nav */}
<nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
const linkContent = (
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--background)] hover:text-[var(--foreground)]",
isCollapsed && "justify-center px-2"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!isCollapsed && <span>{item.label}</span>}
</Link>
);
if (isCollapsed) {
return (
<Tooltip key={item.href} delayDuration={0}>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
return <div key={item.href}>{linkContent}</div>;
})}
</nav>
<Separator />
{/* Bottom nav */}
<nav className="space-y-1 p-2">
{bottomItems.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
const linkContent = (
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--background)] hover:text-[var(--foreground)]",
isCollapsed && "justify-center px-2"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{!isCollapsed && <span>{item.label}</span>}
</Link>
);
if (isCollapsed) {
return (
<Tooltip key={item.href} delayDuration={0}>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
return <div key={item.href}>{linkContent}</div>;
})}
</nav>
</aside>
);
}

View file

@ -0,0 +1,41 @@
"use client";
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { Breadcrumbs } from "@/components/layout/breadcrumbs";
import { Badge } from "@/components/ui/badge";
export function Topbar() {
return (
<header className="flex h-14 items-center justify-between border-b bg-[var(--background)] px-6">
<Breadcrumbs />
<div className="flex items-center gap-2">
<ThemeToggle />
<Button variant="ghost" size="icon" className="relative h-8 w-8">
<Bell className="h-4 w-4" />
{/* Notification badge — will be wired to real data later */}
<Badge
variant="destructive"
className="absolute -right-1 -top-1 h-4 w-4 rounded-full p-0 text-[10px] leading-4"
>
0
</Badge>
</Button>
</div>
</header>
);
}

View file

@ -0,0 +1,13 @@
import { create } from "zustand";
interface SidebarState {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (collapsed: boolean) => void;
}
export const useSidebarStore = create<SidebarState>((set) => ({
isCollapsed: false,
toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
setCollapsed: (collapsed) => set({ isCollapsed: collapsed }),
}));