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:
parent
b4ae910cf5
commit
afa98282ff
11 changed files with 345 additions and 0 deletions
10
src/app/(app)/dashboard/page.tsx
Normal file
10
src/app/(app)/dashboard/page.tsx
Normal 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
14
src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(app)/my-work/page.tsx
Normal file
10
src/app/(app)/my-work/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(app)/notifications/page.tsx
Normal file
10
src/app/(app)/notifications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(app)/projects/page.tsx
Normal file
10
src/app/(app)/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/(app)/settings/page.tsx
Normal file
10
src/app/(app)/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/layout/breadcrumbs.tsx
Normal file
61
src/components/layout/breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
src/components/layout/sidebar.tsx
Normal file
137
src/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/layout/theme-toggle.tsx
Normal file
41
src/components/layout/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/layout/topbar.tsx
Normal file
29
src/components/layout/topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/stores/sidebar-store.ts
Normal file
13
src/stores/sidebar-store.ts
Normal 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 }),
|
||||
}));
|
||||
Loading…
Add table
Reference in a new issue