From a1c3c2f43c4b785dff20d0f486f2d73cb25d745e Mon Sep 17 00:00:00 2001 From: Pierre Wessman <4029607+pierrewessman@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:46:15 +0100 Subject: [PATCH] dashboard v1 --- README.md | 144 ++- app/globals.css | 143 ++- app/layout.tsx | 34 +- app/page.tsx | 106 +-- components.json | 22 + components/charts/bar-chart.tsx | 71 ++ components/charts/line-chart.tsx | 75 ++ components/dashboard/dashboard-tabs.tsx | 29 + components/dashboard/metric-card.tsx | 30 + components/dashboard/payments-table.tsx | 98 +++ components/dashboard/revenue-chart.tsx | 36 + components/dashboard/sales-chart.tsx | 32 + components/dashboard/team-table.tsx | 70 ++ components/layout/app-sidebar.tsx | 166 ++++ components/layout/header.tsx | 30 + components/layout/theme-toggle.tsx | 40 + components/layout/user-nav.tsx | 42 + components/theme-provider.tsx | 11 + components/ui/avatar.tsx | 53 ++ components/ui/badge.tsx | 46 + components/ui/button.tsx | 60 ++ components/ui/card.tsx | 92 ++ components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 ++++ components/ui/dialog.tsx | 143 +++ components/ui/dropdown-menu.tsx | 257 ++++++ components/ui/input.tsx | 21 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 +++ components/ui/sidebar.tsx | 726 +++++++++++++++ components/ui/skeleton.tsx | 13 + components/ui/table.tsx | 116 +++ components/ui/tabs.tsx | 66 ++ components/ui/tooltip.tsx | 61 ++ hooks/use-mobile.ts | 19 + lib/chart-config.ts | 47 + lib/mock-data.ts | 176 ++++ lib/utils.ts | 6 + package.json | 20 +- pnpm-lock.yaml | 1067 +++++++++++++++++++++++ types/dashboard.ts | 36 + 41 files changed, 4480 insertions(+), 108 deletions(-) create mode 100644 components.json create mode 100644 components/charts/bar-chart.tsx create mode 100644 components/charts/line-chart.tsx create mode 100644 components/dashboard/dashboard-tabs.tsx create mode 100644 components/dashboard/metric-card.tsx create mode 100644 components/dashboard/payments-table.tsx create mode 100644 components/dashboard/revenue-chart.tsx create mode 100644 components/dashboard/sales-chart.tsx create mode 100644 components/dashboard/team-table.tsx create mode 100644 components/layout/app-sidebar.tsx create mode 100644 components/layout/header.tsx create mode 100644 components/layout/theme-toggle.tsx create mode 100644 components/layout/user-nav.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 hooks/use-mobile.ts create mode 100644 lib/chart-config.ts create mode 100644 lib/mock-data.ts create mode 100644 lib/utils.ts create mode 100644 types/dashboard.ts diff --git a/README.md b/README.md index e215bc4..b63807e 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,140 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# shadcn Dashboard + +A modern, full-featured dashboard application built with Next.js 14, shadcn/ui, and Chart.js. + +## Features + +- **Modern Stack**: Next.js 14 App Router, TypeScript, Tailwind CSS v4 +- **UI Components**: shadcn/ui component library with custom theming +- **Dark Mode**: Full dark/light theme support with system detection +- **Data Visualization**: Interactive charts using Chart.js +- **Responsive Design**: Mobile-first responsive layout +- **Sidebar Navigation**: Collapsible sidebar with nested navigation +- **Dashboard Components**: + - 4 metric cards with trend indicators + - Revenue overview line chart + - Sales activity bar chart + - Recent payments table with status badges + - Team members table with avatars ## Getting Started -First, run the development server: +### Development ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +# Install dependencies +pnpm install + +# Run development server +pnpm run dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open [http://localhost:3000](http://localhost:3000) to view the dashboard. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Build -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +```bash +# Create production build +pnpm run build -## Learn More +# Start production server +pnpm start +``` -To learn more about Next.js, take a look at the following resources: +## Project Structure -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +``` +shadcn-dashboard/ +├── app/ +│ ├── layout.tsx # Root layout with providers +│ ├── page.tsx # Main dashboard page +│ └── globals.css # Global styles +├── components/ +│ ├── ui/ # shadcn/ui components +│ ├── layout/ +│ │ ├── app-sidebar.tsx # Navigation sidebar +│ │ ├── header.tsx # Top header +│ │ ├── theme-toggle.tsx # Theme switcher +│ │ └── user-nav.tsx # User profile dropdown +│ ├── dashboard/ +│ │ ├── metric-card.tsx # Metric display cards +│ │ ├── revenue-chart.tsx # Revenue line chart +│ │ ├── sales-chart.tsx # Sales bar chart +│ │ ├── payments-table.tsx # Payments data table +│ │ ├── team-table.tsx # Team members table +│ │ └── dashboard-tabs.tsx # Tab navigation +│ ├── charts/ +│ │ ├── line-chart.tsx # Line chart wrapper +│ │ └── bar-chart.tsx # Bar chart wrapper +│ └── theme-provider.tsx # Theme context provider +├── lib/ +│ ├── utils.ts # Utility functions +│ ├── mock-data.ts # Mock dashboard data +│ └── chart-config.ts # Chart.js configuration +└── types/ + └── dashboard.ts # TypeScript interfaces +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Tech Stack -## Deploy on Vercel +- **Framework**: [Next.js 14](https://nextjs.org/) (App Router) +- **Language**: [TypeScript](https://www.typescriptlang.org/) +- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) +- **UI Components**: [shadcn/ui](https://ui.shadcn.com/) +- **Charts**: [Chart.js](https://www.chartjs.org/) + [react-chartjs-2](https://react-chartjs-2.js.org/) +- **Theme**: [next-themes](https://github.com/pacocoursey/next-themes) +- **Icons**: [Lucide React](https://lucide.dev/) +- **Package Manager**: [pnpm](https://pnpm.io/) -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Features Implemented -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +### Phase 1: Project Foundation ✅ +- Next.js 14 project initialization +- shadcn/ui setup +- All required dependencies installed + +### Phase 2: Theme & Layout ✅ +- Theme provider with dark/light mode +- Collapsible sidebar navigation +- Header with search and theme toggle +- User profile dropdown + +### Phase 3: Data Layer ✅ +- TypeScript interfaces for all data types +- Mock data generation +- Type-safe data handling + +### Phase 4: Charts ✅ +- Chart.js configuration with theme support +- Line chart component +- Bar chart component +- Theme-aware color schemes + +### Phase 5: Dashboard Components ✅ +- Metric cards with trend indicators +- Revenue overview chart +- Sales activity chart +- Payments table with actions +- Team members table + +### Phase 6: Assembly & Polish ✅ +- Complete dashboard page layout +- Responsive grid system +- Custom scrollbar styling +- Inter font integration + +### Phase 7: Testing ✅ +- Development server verified +- Production build successful +- No TypeScript errors +- All components rendering correctly + +## Development Notes + +See [CLAUDE.md](CLAUDE.md) for development-specific notes. + +See [PLAN.md](PLAN.md) for the detailed implementation plan. + +## License + +MIT diff --git a/app/globals.css b/app/globals.css index a2dc41e..c50fb50 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,26 +1,145 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + + html { + scroll-behavior: smooth; } } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +@layer utilities { + .scrollbar-thin::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + @apply bg-transparent; + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + @apply bg-border rounded-full; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + @apply bg-muted-foreground/50; + } } diff --git a/app/layout.tsx b/app/layout.tsx index f7fa87e..291e954 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,17 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter } from "next/font/google"; import "./globals.css"; +import { ThemeProvider } from "@/components/theme-provider"; +import { SidebarProvider } from "@/components/ui/sidebar"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const inter = Inter({ subsets: ["latin"], + variable: "--font-inter", }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Dashboard - shadcn/ui", + description: "Modern dashboard built with Next.js and shadcn/ui", }; export default function RootLayout({ @@ -23,11 +20,18 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} + + + + + {children} + + ); diff --git a/app/page.tsx b/app/page.tsx index 295f8fd..84684f5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,65 +1,51 @@ -import Image from "next/image"; +import { AppSidebar } from "@/components/layout/app-sidebar" +import { Header } from "@/components/layout/header" +import { SidebarInset } from "@/components/ui/sidebar" +import { DashboardTabs } from "@/components/dashboard/dashboard-tabs" +import { MetricCard } from "@/components/dashboard/metric-card" +import { RevenueChart } from "@/components/dashboard/revenue-chart" +import { SalesChart } from "@/components/dashboard/sales-chart" +import { PaymentsTable } from "@/components/dashboard/payments-table" +import { TeamTable } from "@/components/dashboard/team-table" +import { getDashboardData } from "@/lib/mock-data" export default function Home() { + const data = getDashboardData() + return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+ <> + + +
+
+
+

Dashboard

+
+ +
+ {data.metrics.map((metric) => ( + + ))} +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
- -
-
- ); + + + ) } diff --git a/components.json b/components.json new file mode 100644 index 0000000..b7b9791 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/components/charts/bar-chart.tsx b/components/charts/bar-chart.tsx new file mode 100644 index 0000000..3070baf --- /dev/null +++ b/components/charts/bar-chart.tsx @@ -0,0 +1,71 @@ +"use client" + +import { Bar } from "react-chartjs-2" +import { useTheme } from "next-themes" +import { getChartColors, defaultChartOptions } from "@/lib/chart-config" +import type { ChartOptions } from "chart.js" + +interface BarChartProps { + data: { + labels: string[] + datasets: { + label: string + data: number[] + backgroundColor?: string + }[] + } + options?: ChartOptions<"bar"> + height?: number +} + +export function BarChart({ data, options, height = 300 }: BarChartProps) { + const { theme } = useTheme() + const colors = getChartColors(theme) + + const chartData = { + ...data, + datasets: data.datasets.map((dataset) => ({ + ...dataset, + backgroundColor: dataset.backgroundColor || colors.primary, + })), + } + + const chartOptions: ChartOptions<"bar"> = { + ...defaultChartOptions, + ...options, + scales: { + y: { + grid: { + color: colors.grid, + }, + ticks: { + color: colors.text, + }, + }, + x: { + grid: { + color: colors.grid, + }, + ticks: { + color: colors.text, + }, + }, + }, + plugins: { + ...defaultChartOptions.plugins, + ...options?.plugins, + legend: { + ...defaultChartOptions.plugins?.legend, + labels: { + color: colors.text, + }, + }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/components/charts/line-chart.tsx b/components/charts/line-chart.tsx new file mode 100644 index 0000000..81d5895 --- /dev/null +++ b/components/charts/line-chart.tsx @@ -0,0 +1,75 @@ +"use client" + +import { Line } from "react-chartjs-2" +import { useTheme } from "next-themes" +import { getChartColors, defaultChartOptions } from "@/lib/chart-config" +import type { ChartOptions } from "chart.js" + +interface LineChartProps { + data: { + labels: string[] + datasets: { + label: string + data: number[] + borderColor?: string + backgroundColor?: string + }[] + } + options?: ChartOptions<"line"> + height?: number +} + +export function LineChart({ data, options, height = 300 }: LineChartProps) { + const { theme } = useTheme() + const colors = getChartColors(theme) + + const chartData = { + ...data, + datasets: data.datasets.map((dataset, index) => ({ + ...dataset, + borderColor: dataset.borderColor || (index === 0 ? colors.primary : colors.secondary), + backgroundColor: dataset.backgroundColor || (index === 0 ? `${colors.primary}20` : `${colors.secondary}20`), + tension: 0.4, + fill: true, + })), + } + + const chartOptions: ChartOptions<"line"> = { + ...defaultChartOptions, + ...options, + scales: { + y: { + grid: { + color: colors.grid, + }, + ticks: { + color: colors.text, + }, + }, + x: { + grid: { + color: colors.grid, + }, + ticks: { + color: colors.text, + }, + }, + }, + plugins: { + ...defaultChartOptions.plugins, + ...options?.plugins, + legend: { + ...defaultChartOptions.plugins?.legend, + labels: { + color: colors.text, + }, + }, + }, + } + + return ( +
+ +
+ ) +} diff --git a/components/dashboard/dashboard-tabs.tsx b/components/dashboard/dashboard-tabs.tsx new file mode 100644 index 0000000..105677e --- /dev/null +++ b/components/dashboard/dashboard-tabs.tsx @@ -0,0 +1,29 @@ +"use client" + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" + +interface DashboardTabsProps { + children: React.ReactNode +} + +export function DashboardTabs({ children }: DashboardTabsProps) { + return ( + + + Overview + + Analytics + + + Reports + + + Notifications + + + + {children} + + + ) +} diff --git a/components/dashboard/metric-card.tsx b/components/dashboard/metric-card.tsx new file mode 100644 index 0000000..63697c1 --- /dev/null +++ b/components/dashboard/metric-card.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { TrendingUp, TrendingDown } from "lucide-react" +import type { MetricCard as MetricCardType } from "@/types/dashboard" + +export function MetricCard({ title, value, change, changeLabel }: MetricCardType) { + const isPositive = change >= 0 + + return ( + + + {title} + + +
{value}
+
+ {isPositive ? ( + + ) : ( + + )} + + {isPositive ? "+" : ""} + {change}% + + {changeLabel} +
+
+
+ ) +} diff --git a/components/dashboard/payments-table.tsx b/components/dashboard/payments-table.tsx new file mode 100644 index 0000000..b76323f --- /dev/null +++ b/components/dashboard/payments-table.tsx @@ -0,0 +1,98 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { MoreHorizontal } from "lucide-react" +import type { Payment } from "@/types/dashboard" + +interface PaymentsTableProps { + payments: Payment[] +} + +export function PaymentsTable({ payments }: PaymentsTableProps) { + const getStatusColor = (status: Payment["status"]) => { + switch (status) { + case "success": + return "bg-green-500/10 text-green-500 hover:bg-green-500/20" + case "processing": + return "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20" + case "failed": + return "bg-red-500/10 text-red-500 hover:bg-red-500/20" + default: + return "" + } + } + + return ( + + + Recent Payments + You have {payments.length} payments this month. + + +
+ + + + Status + Email + Amount + Actions + + + + {payments.map((payment) => ( + + + + {payment.status} + + + {payment.email} + + ${payment.amount.toFixed(2)} + + + + + + + + Actions + View details + Download receipt + + + + + ))} + +
+
+
+
+ ) +} diff --git a/components/dashboard/revenue-chart.tsx b/components/dashboard/revenue-chart.tsx new file mode 100644 index 0000000..eb0233b --- /dev/null +++ b/components/dashboard/revenue-chart.tsx @@ -0,0 +1,36 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { LineChart } from "@/components/charts/line-chart" +import type { ChartDataPoint } from "@/types/dashboard" + +interface RevenueChartProps { + data: ChartDataPoint[] +} + +export function RevenueChart({ data }: RevenueChartProps) { + const chartData = { + labels: data.map((d) => d.label), + datasets: [ + { + label: "Revenue", + data: data.map((d) => d.value), + }, + { + label: "Subscriptions", + data: data.map((d) => d.secondaryValue || 0), + }, + ], + } + + return ( + + + Revenue Overview + + + + + + ) +} diff --git a/components/dashboard/sales-chart.tsx b/components/dashboard/sales-chart.tsx new file mode 100644 index 0000000..d7e5542 --- /dev/null +++ b/components/dashboard/sales-chart.tsx @@ -0,0 +1,32 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { BarChart } from "@/components/charts/bar-chart" +import type { ChartDataPoint } from "@/types/dashboard" + +interface SalesChartProps { + data: ChartDataPoint[] +} + +export function SalesChart({ data }: SalesChartProps) { + const chartData = { + labels: data.map((d) => d.label), + datasets: [ + { + label: "Sales", + data: data.map((d) => d.value), + }, + ], + } + + return ( + + + Sales Activity + + + + + + ) +} diff --git a/components/dashboard/team-table.tsx b/components/dashboard/team-table.tsx new file mode 100644 index 0000000..636f4e9 --- /dev/null +++ b/components/dashboard/team-table.tsx @@ -0,0 +1,70 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import type { TeamMember } from "@/types/dashboard" + +interface TeamTableProps { + team: TeamMember[] +} + +export function TeamTable({ team }: TeamTableProps) { + const getInitials = (name: string) => { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + } + + return ( + + + Team Members + You have {team.length} team members. + + +
+ + + + Member + Email + Role + + + + {team.map((member) => ( + + +
+ + + {getInitials(member.name)} + + {member.name} +
+
+ {member.email} + {member.role} +
+ ))} +
+
+
+
+
+ ) +} diff --git a/components/layout/app-sidebar.tsx b/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..180cf00 --- /dev/null +++ b/components/layout/app-sidebar.tsx @@ -0,0 +1,166 @@ +"use client" + +import * as React from "react" +import { + LayoutDashboard, + CheckSquare, + Users, + Lock, + AlertTriangle, + Settings, + Code, + ChevronRight, +} from "lucide-react" + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/ui/sidebar" +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { UserNav } from "./user-nav" + +const navigation = [ + { + title: "General", + items: [ + { + title: "Dashboard", + icon: LayoutDashboard, + url: "/", + subItems: [ + { title: "Overview", url: "/" }, + { title: "Analytics", url: "/analytics" }, + { title: "Reports", url: "/reports" }, + ], + }, + { + title: "Tasks", + icon: CheckSquare, + url: "/tasks", + }, + { + title: "Users", + icon: Users, + url: "/users", + }, + ], + }, + { + title: "Pages", + items: [ + { + title: "Auth", + icon: Lock, + url: "/auth", + }, + { + title: "Errors", + icon: AlertTriangle, + url: "/errors", + }, + ], + }, + { + title: "Other", + items: [ + { + title: "Settings", + icon: Settings, + url: "/settings", + }, + { + title: "Developers", + icon: Code, + url: "/developers", + }, + ], + }, +] + +export function AppSidebar() { + return ( + + +
+

+ Dashboard +

+
+ {navigation.map((section) => ( + + {section.title} + + + {section.items.map((item) => + item.subItems ? ( + + + + + {item.icon && } + {item.title} + + + + + + {item.subItems.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + + ) : ( + + + + {item.icon && } + {item.title} + + + + ) + )} + + + + ))} +
+ +
+ +
+

shadcn

+

m@example.com

+
+
+
+
+ ) +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx new file mode 100644 index 0000000..f03773c --- /dev/null +++ b/components/layout/header.tsx @@ -0,0 +1,30 @@ +"use client" + +import { SidebarTrigger } from "@/components/ui/sidebar" +import { Separator } from "@/components/ui/separator" +import { ThemeToggle } from "./theme-toggle" +import { Button } from "@/components/ui/button" +import { Command } from "lucide-react" + +export function Header() { + return ( +
+ + + +
+ + + +
+
+ ) +} diff --git a/components/layout/theme-toggle.tsx b/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..7a53e53 --- /dev/null +++ b/components/layout/theme-toggle.tsx @@ -0,0 +1,40 @@ +"use client" + +import * as React from "react" +import { Moon, Sun } 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 } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} diff --git a/components/layout/user-nav.tsx b/components/layout/user-nav.tsx new file mode 100644 index 0000000..ba930b1 --- /dev/null +++ b/components/layout/user-nav.tsx @@ -0,0 +1,42 @@ +"use client" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function UserNav() { + return ( + + + + + + +
+

shadcn

+

+ m@example.com +

+
+
+ + Profile + Settings + + Sign out +
+
+ ) +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..6a1ffe4 --- /dev/null +++ b/components/theme-provider.tsx @@ -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) { + return {children} +} diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..fd3a406 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..21409a0 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/collapsible.tsx b/components/ui/collapsible.tsx new file mode 100644 index 0000000..ae9fad0 --- /dev/null +++ b/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/components/ui/command.tsx b/components/ui/command.tsx new file mode 100644 index 0000000..8cb4ca7 --- /dev/null +++ b/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..d9ccec9 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..bbe6fb0 --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..8916905 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/components/ui/separator.tsx b/components/ui/separator.tsx new file mode 100644 index 0000000..275381c --- /dev/null +++ b/components/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 0000000..84649ad --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/components/ui/sidebar.tsx b/components/ui/sidebar.tsx new file mode 100644 index 0000000..30638ac --- /dev/null +++ b/components/ui/sidebar.tsx @@ -0,0 +1,726 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { PanelLeftIcon } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +function SidebarProvider({ + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props +}: React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) +} + +function Sidebar({ + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props +}: React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" +}) { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +