set-up
This commit is contained in:
@@ -1,34 +0,0 @@
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { pokemonOptions } from '@/features/react-query-demo/api/queries';
|
||||
import { PokemonInfo } from '@/features/react-query-demo/components/pokemon-info';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { Suspense } from 'react';
|
||||
import { PokemonSkeleton } from '@/features/react-query-demo/components/pokemon-skeleton';
|
||||
import { reactQueryInfoContent } from '@/features/react-query-demo/info-content';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: React Query'
|
||||
};
|
||||
|
||||
export default function ReactQueryPage() {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
// Prefetch on the server — data is ready before client JS loads
|
||||
void queryClient.prefetchQuery(pokemonOptions(25));
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
scrollable
|
||||
pageTitle='React Query'
|
||||
pageDescription='Server prefetch + client hydration + suspense query pattern.'
|
||||
infoContent={reactQueryInfoContent}
|
||||
>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense fallback={<PokemonSkeleton />}>
|
||||
<PokemonInfo />
|
||||
</Suspense>
|
||||
</HydrationBoundary>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import UserListingPage from '@/features/users/components/user-listing';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { SearchParams } from 'nuqs/server';
|
||||
import { usersInfoContent } from '@/features/users/info-content';
|
||||
import { UserFormSheetTrigger } from '@/features/users/components/user-form-sheet';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: Users'
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function UsersPage(props: PageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
scrollable={false}
|
||||
pageTitle='Users'
|
||||
pageDescription='Manage users (React Query + nuqs table pattern.)'
|
||||
infoContent={usersInfoContent}
|
||||
pageHeaderAction={<UserFormSheetTrigger />}
|
||||
>
|
||||
<UserListingPage />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
27
src/app/global-error.tsx
Normal file
27
src/app/global-error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import NextError from 'next/error';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function GlobalError({
|
||||
error
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--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) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
@import './theme.css';
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -67,12 +26,11 @@
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--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);
|
||||
@@ -88,7 +46,7 @@
|
||||
--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: oklch(0.269 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
@@ -96,17 +54,17 @@
|
||||
--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: oklch(0.371 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.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 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);
|
||||
@@ -114,7 +72,45 @@
|
||||
--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);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -124,7 +120,39 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
/* View Transition Wave Effect */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
/* Ensure the outgoing view (old theme) is beneath */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
/* Ensure the incoming view (new theme) is always on top */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from {
|
||||
/* Use CSS variables for the origin, defaulting to center if not set */
|
||||
clip-path: circle(0% at var(--x, 50%) var(--y, 50%));
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
to {
|
||||
/* Use CSS variables for the origin, defaulting to center if not set */
|
||||
clip-path: circle(150% at var(--x, 50%) var(--y, 50%));
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
/* Apply the reveal animation */
|
||||
animation: reveal 0.4s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,77 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Providers from '@/components/layout/providers';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { fontVariables } from '@/lib/font';
|
||||
import ThemeProvider from '@/components/layout/ThemeToggle/theme-provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import './globals.css';
|
||||
import './theme.css';
|
||||
|
||||
const inter = Inter({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
const META_THEME_COLORS = {
|
||||
light: '#ffffff',
|
||||
dark: '#09090b'
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
export const metadata: Metadata = {
|
||||
title: 'Next Shadcn',
|
||||
description: 'Basic dashboard with Next.js and Shadcn'
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: META_THEME_COLORS.light
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const activeThemeValue = cookieStore.get('active_theme')?.value;
|
||||
const isScaled = activeThemeValue?.endsWith('-scaled');
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
|
||||
}
|
||||
} catch (_) {}
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
'bg-background overflow-hidden overscroll-none font-sans antialiased',
|
||||
activeThemeValue ? `theme-${activeThemeValue}` : '',
|
||||
isScaled ? 'theme-scaled' : '',
|
||||
fontVariables
|
||||
)}
|
||||
>
|
||||
<NextTopLoader color='var(--primary)' showSpinner={false} />
|
||||
<NuqsAdapter>
|
||||
<ThemeProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
<Providers activeThemeValue={activeThemeValue as string}>
|
||||
<Toaster />
|
||||
{children}
|
||||
</Providers>
|
||||
</ThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/app/not-found.tsx
Normal file
36
src/app/not-found.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function NotFound() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className='absolute top-1/2 left-1/2 mb-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-center'>
|
||||
<span className='from-foreground bg-linear-to-b to-transparent bg-clip-text text-[10rem] leading-none font-extrabold text-transparent'>
|
||||
404
|
||||
</span>
|
||||
<h2 className='font-heading my-2 text-2xl font-bold'>
|
||||
Something's missing
|
||||
</h2>
|
||||
<p>
|
||||
Sorry, the page you are looking for doesn't exist or has been
|
||||
moved.
|
||||
</p>
|
||||
<div className='mt-8 flex justify-center gap-2'>
|
||||
<Button onClick={() => router.back()} variant='default' size='lg'>
|
||||
Go back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
variant='ghost'
|
||||
size='lg'
|
||||
>
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,12 @@
|
||||
import Image from "next/image";
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
export default async function Page() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return redirect('/auth/sign-in');
|
||||
} else {
|
||||
redirect('/dashboard/overview');
|
||||
}
|
||||
}
|
||||
|
||||
105
src/app/theme.css
Normal file
105
src/app/theme.css
Normal file
@@ -0,0 +1,105 @@
|
||||
body {
|
||||
@apply overscroll-none bg-transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-sans: var(--font-inter);
|
||||
--header-height: calc(var(--spacing) * 12 + 1px);
|
||||
}
|
||||
|
||||
.theme-scaled {
|
||||
@media (min-width: 1024px) {
|
||||
--radius: 0.6rem;
|
||||
--text-lg: 1.05rem;
|
||||
--text-base: 0.85rem;
|
||||
--text-sm: 0.8rem;
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
|
||||
[data-slot='card'] {
|
||||
--spacing: 0.16rem;
|
||||
}
|
||||
|
||||
[data-slot='select-trigger'],
|
||||
[data-slot='toggle-group-item'] {
|
||||
--spacing: 0.222222rem;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-default,
|
||||
.theme-default-scaled {
|
||||
--primary: var(--color-neutral-600);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-neutral-500);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-blue,
|
||||
.theme-blue-scaled {
|
||||
--primary: var(--color-blue-600);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-blue-500);
|
||||
--primary-foreground: var(--color-blue-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-green,
|
||||
.theme-green-scaled {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-lime-600);
|
||||
--primary-foreground: var(--color-lime-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-amber,
|
||||
.theme-amber-scaled {
|
||||
--primary: var(--color-amber-600);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-amber-500);
|
||||
--primary-foreground: var(--color-amber-50);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-mono,
|
||||
.theme-mono-scaled {
|
||||
--font-sans: var(--font-mono);
|
||||
--primary: var(--color-neutral-600);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
|
||||
@variant dark {
|
||||
--primary: var(--color-neutral-500);
|
||||
--primary-foreground: var(--color-neutral-50);
|
||||
}
|
||||
|
||||
.rounded-xs,
|
||||
.rounded-sm,
|
||||
.rounded-md,
|
||||
.rounded-lg,
|
||||
.rounded-xl {
|
||||
@apply !rounded-none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.shadow-xs,
|
||||
.shadow-sm,
|
||||
.shadow-md,
|
||||
.shadow-lg,
|
||||
.shadow-xl {
|
||||
@apply !shadow-none;
|
||||
}
|
||||
|
||||
[data-slot='toggle-group'],
|
||||
[data-slot='toggle-group-item'] {
|
||||
@apply !rounded-none !shadow-none;
|
||||
}
|
||||
}
|
||||
67
src/components/active-theme.tsx
Normal file
67
src/components/active-theme.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
const COOKIE_NAME = 'active_theme';
|
||||
const DEFAULT_THEME = 'default';
|
||||
|
||||
function setThemeCookie(theme: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
document.cookie = `${COOKIE_NAME}=${theme}; path=/; max-age=31536000; SameSite=Lax; ${window.location.protocol === 'https:' ? 'Secure;' : ''}`;
|
||||
}
|
||||
|
||||
type ThemeContextType = {
|
||||
activeTheme: string;
|
||||
setActiveTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ActiveThemeProvider({
|
||||
children,
|
||||
initialTheme
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialTheme?: string;
|
||||
}) {
|
||||
const [activeTheme, setActiveTheme] = useState<string>(
|
||||
() => initialTheme || DEFAULT_THEME
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setThemeCookie(activeTheme);
|
||||
|
||||
Array.from(document.body.classList)
|
||||
.filter((className) => className.startsWith('theme-'))
|
||||
.forEach((className) => {
|
||||
document.body.classList.remove(className);
|
||||
});
|
||||
document.body.classList.add(`theme-${activeTheme}`);
|
||||
if (activeTheme.endsWith('-scaled')) {
|
||||
document.body.classList.add('theme-scaled');
|
||||
}
|
||||
}, [activeTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ activeTheme, setActiveTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThemeConfig() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useThemeConfig must be used within an ActiveThemeProvider'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
41
src/components/breadcrumbs.tsx
Normal file
41
src/components/breadcrumbs.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { useBreadcrumbs } from '@/hooks/use-breadcrumbs';
|
||||
import { IconSlash } from '@tabler/icons-react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const items = useBreadcrumbs();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.title}>
|
||||
{index !== items.length - 1 && (
|
||||
<BreadcrumbItem className='hidden md:block'>
|
||||
<BreadcrumbLink href={item.link}>{item.title}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
{index < items.length - 1 && (
|
||||
<BreadcrumbSeparator className='hidden md:block'>
|
||||
<IconSlash />
|
||||
</BreadcrumbSeparator>
|
||||
)}
|
||||
{index === items.length - 1 && (
|
||||
<BreadcrumbPage>{item.title}</BreadcrumbPage>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
317
src/components/file-uploader.tsx
Normal file
317
src/components/file-uploader.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
'use client';
|
||||
|
||||
import { IconX, IconUpload } from '@tabler/icons-react';
|
||||
import Image from 'next/image';
|
||||
import * as React from 'react';
|
||||
import Dropzone, {
|
||||
type DropzoneProps,
|
||||
type FileRejection
|
||||
} from 'react-dropzone';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useControllableState } from '@/hooks/use-controllable-state';
|
||||
import { cn, formatBytes } from '@/lib/utils';
|
||||
|
||||
export interface FileUploaderProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Value of the uploader.
|
||||
* @type File[]
|
||||
* @default undefined
|
||||
* @example value={files}
|
||||
*/
|
||||
value?: File[];
|
||||
|
||||
/**
|
||||
* Function to be called when the value changes.
|
||||
* @type React.Dispatch<React.SetStateAction<File[]>>
|
||||
* @default undefined
|
||||
* @example onValueChange={(files) => setFiles(files)}
|
||||
*/
|
||||
onValueChange?: React.Dispatch<React.SetStateAction<File[]>>;
|
||||
|
||||
/**
|
||||
* Function to be called when files are uploaded.
|
||||
* @type (files: File[]) => Promise<void>
|
||||
* @default undefined
|
||||
* @example onUpload={(files) => uploadFiles(files)}
|
||||
*/
|
||||
onUpload?: (files: File[]) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Progress of the uploaded files.
|
||||
* @type Record<string, number> | undefined
|
||||
* @default undefined
|
||||
* @example progresses={{ "file1.png": 50 }}
|
||||
*/
|
||||
progresses?: Record<string, number>;
|
||||
|
||||
/**
|
||||
* Accepted file types for the uploader.
|
||||
* @type { [key: string]: string[]}
|
||||
* @default
|
||||
* ```ts
|
||||
* { "image/*": [] }
|
||||
* ```
|
||||
* @example accept={["image/png", "image/jpeg"]}
|
||||
*/
|
||||
accept?: DropzoneProps['accept'];
|
||||
|
||||
/**
|
||||
* Maximum file size for the uploader.
|
||||
* @type number | undefined
|
||||
* @default 1024 * 1024 * 2 // 2MB
|
||||
* @example maxSize={1024 * 1024 * 2} // 2MB
|
||||
*/
|
||||
maxSize?: DropzoneProps['maxSize'];
|
||||
|
||||
/**
|
||||
* Maximum number of files for the uploader.
|
||||
* @type number | undefined
|
||||
* @default 1
|
||||
* @example maxFiles={5}
|
||||
*/
|
||||
maxFiles?: DropzoneProps['maxFiles'];
|
||||
|
||||
/**
|
||||
* Whether the uploader should accept multiple files.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example multiple
|
||||
*/
|
||||
multiple?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the uploader is disabled.
|
||||
* @type boolean
|
||||
* @default false
|
||||
* @example disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FileUploader(props: FileUploaderProps) {
|
||||
const {
|
||||
value: valueProp,
|
||||
onValueChange,
|
||||
onUpload,
|
||||
progresses,
|
||||
accept = { 'image/*': [] },
|
||||
maxSize = 1024 * 1024 * 2,
|
||||
maxFiles = 1,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
className,
|
||||
...dropzoneProps
|
||||
} = props;
|
||||
|
||||
const [files, setFiles] = useControllableState({
|
||||
prop: valueProp,
|
||||
onChange: onValueChange
|
||||
});
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
if (!multiple && maxFiles === 1 && acceptedFiles.length > 1) {
|
||||
toast.error('Cannot upload more than 1 file at a time');
|
||||
return;
|
||||
}
|
||||
|
||||
if ((files?.length ?? 0) + acceptedFiles.length > maxFiles) {
|
||||
toast.error(`Cannot upload more than ${maxFiles} files`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = acceptedFiles.map((file) =>
|
||||
Object.assign(file, {
|
||||
preview: URL.createObjectURL(file)
|
||||
})
|
||||
);
|
||||
|
||||
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
|
||||
|
||||
setFiles(updatedFiles);
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
rejectedFiles.forEach(({ file }) => {
|
||||
toast.error(`File ${file.name} was rejected`);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
onUpload &&
|
||||
updatedFiles.length > 0 &&
|
||||
updatedFiles.length <= maxFiles
|
||||
) {
|
||||
const target =
|
||||
updatedFiles.length > 0 ? `${updatedFiles.length} files` : `file`;
|
||||
|
||||
toast.promise(onUpload(updatedFiles), {
|
||||
loading: `Uploading ${target}...`,
|
||||
success: () => {
|
||||
setFiles([]);
|
||||
return `${target} uploaded`;
|
||||
},
|
||||
error: `Failed to upload ${target}`
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
[files, maxFiles, multiple, onUpload, setFiles]
|
||||
);
|
||||
|
||||
function onRemove(index: number) {
|
||||
if (!files) return;
|
||||
const newFiles = files.filter((_, i) => i !== index);
|
||||
setFiles(newFiles);
|
||||
onValueChange?.(newFiles);
|
||||
}
|
||||
|
||||
// Revoke preview url when component unmounts
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (!files) return;
|
||||
files.forEach((file) => {
|
||||
if (isFileWithPreview(file)) {
|
||||
URL.revokeObjectURL(file.preview);
|
||||
}
|
||||
});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isDisabled = disabled || (files?.length ?? 0) >= maxFiles;
|
||||
|
||||
return (
|
||||
<div className='relative flex flex-col gap-6 overflow-hidden'>
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
accept={accept}
|
||||
maxSize={maxSize}
|
||||
maxFiles={maxFiles}
|
||||
multiple={maxFiles > 1 || multiple}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{({ getRootProps, getInputProps, isDragActive }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
'group border-muted-foreground/25 hover:bg-muted/25 relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed px-5 py-2.5 text-center transition',
|
||||
'ring-offset-background focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden',
|
||||
isDragActive && 'border-muted-foreground/50',
|
||||
isDisabled && 'pointer-events-none opacity-60',
|
||||
className
|
||||
)}
|
||||
{...dropzoneProps}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
|
||||
<div className='rounded-full border border-dashed p-3'>
|
||||
<IconUpload
|
||||
className='text-muted-foreground size-7'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<p className='text-muted-foreground font-medium'>
|
||||
Drop the files here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col items-center justify-center gap-4 sm:px-5'>
|
||||
<div className='rounded-full border border-dashed p-3'>
|
||||
<IconUpload
|
||||
className='text-muted-foreground size-7'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-px'>
|
||||
<p className='text-muted-foreground font-medium'>
|
||||
Drag {`'n'`} drop files here, or click to select files
|
||||
</p>
|
||||
<p className='text-muted-foreground/70 text-sm'>
|
||||
You can upload
|
||||
{maxFiles > 1
|
||||
? ` ${maxFiles === Infinity ? 'multiple' : maxFiles}
|
||||
files (up to ${formatBytes(maxSize)} each)`
|
||||
: ` a file with ${formatBytes(maxSize)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
{files?.length ? (
|
||||
<ScrollArea className='h-fit w-full px-3'>
|
||||
<div className='max-h-48 space-y-4'>
|
||||
{files?.map((file, index) => (
|
||||
<FileCard
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => onRemove(index)}
|
||||
progress={progresses?.[file.name]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
function FileCard({ file, progress, onRemove }: FileCardProps) {
|
||||
return (
|
||||
<div className='relative flex items-center space-x-4'>
|
||||
<div className='flex flex-1 space-x-4'>
|
||||
{isFileWithPreview(file) ? (
|
||||
<Image
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
width={48}
|
||||
height={48}
|
||||
loading='lazy'
|
||||
className='aspect-square shrink-0 rounded-md object-cover'
|
||||
/>
|
||||
) : null}
|
||||
<div className='flex w-full flex-col gap-2'>
|
||||
<div className='space-y-px'>
|
||||
<p className='text-foreground/80 line-clamp-1 text-sm font-medium'>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
{progress ? <Progress value={progress} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={onRemove}
|
||||
disabled={progress !== undefined && progress < 100}
|
||||
className='size-8 rounded-full'
|
||||
>
|
||||
<IconX className='text-muted-foreground' />
|
||||
<span className='sr-only'>Remove file</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isFileWithPreview(file: File): file is File & { preview: string } {
|
||||
return 'preview' in file && typeof file.preview === 'string';
|
||||
}
|
||||
52
src/components/form-card-skeleton.tsx
Normal file
52
src/components/form-card-skeleton.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader } from './ui/card';
|
||||
import { Skeleton } from './ui/skeleton';
|
||||
|
||||
export default function FormCardSkeleton() {
|
||||
return (
|
||||
<Card className='mx-auto w-full'>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-8 w-48' /> {/* Title */}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='space-y-8'>
|
||||
{/* Image upload area skeleton */}
|
||||
<div className='space-y-6'>
|
||||
<Skeleton className='h-4 w-16' /> {/* Label */}
|
||||
<Skeleton className='h-32 w-full rounded-lg' /> {/* Upload area */}
|
||||
</div>
|
||||
|
||||
{/* Grid layout for form fields */}
|
||||
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
|
||||
{/* Product Name field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-24' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Category field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-20' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Select */}
|
||||
</div>
|
||||
|
||||
{/* Price field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-24' /> {/* Label */}
|
||||
<Skeleton className='h-32 w-full' /> {/* Textarea */}
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Skeleton className='h-10 w-28' />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
303
src/components/forms/demo-form.tsx
Normal file
303
src/components/forms/demo-form.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { FormInput } from './form-input';
|
||||
import { FormTextarea } from './form-textarea';
|
||||
import { FormSelect, type FormOption } from './form-select';
|
||||
import {
|
||||
FormCheckboxGroup,
|
||||
type CheckboxGroupOption
|
||||
} from './form-checkbox-group';
|
||||
import { FormRadioGroup, type RadioGroupOption } from './form-radio-group';
|
||||
import { FormSwitch } from './form-switch';
|
||||
import { FormSlider } from './form-slider';
|
||||
import { FormDatePicker } from './form-date-picker';
|
||||
import { FormCheckbox } from './form-checkbox';
|
||||
import { FormFileUpload, type FileUploadConfig } from './form-file-upload';
|
||||
|
||||
// Demo form schema
|
||||
const demoFormSchema = z.object({
|
||||
// Basic inputs
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.email('Invalid email address'),
|
||||
age: z.number().min(18, 'Must be at least 18 years old'),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
|
||||
// Textarea
|
||||
bio: z.string().min(10, 'Bio must be at least 10 characters'),
|
||||
|
||||
// Select
|
||||
country: z.string().min(1, 'Please select a country'),
|
||||
|
||||
// Checkbox group
|
||||
interests: z.array(z.string()).min(1, 'Select at least one interest'),
|
||||
|
||||
// Radio group
|
||||
gender: z.string().min(1, 'Please select gender'),
|
||||
|
||||
// Switch
|
||||
newsletter: z.boolean(),
|
||||
|
||||
// Slider
|
||||
rating: z.number().min(0).max(10),
|
||||
|
||||
// Date picker
|
||||
birthDate: z.date().optional(),
|
||||
|
||||
// Single checkbox
|
||||
terms: z.boolean().refine((val) => val === true, 'You must accept the terms'),
|
||||
|
||||
// File upload
|
||||
avatar: z.array(z.any()).optional()
|
||||
});
|
||||
|
||||
type DemoFormData = z.infer<typeof demoFormSchema>;
|
||||
|
||||
// Demo options
|
||||
const countryOptions: FormOption[] = [
|
||||
{ value: 'us', label: 'United States' },
|
||||
{ value: 'ca', label: 'Canada' },
|
||||
{ value: 'uk', label: 'United Kingdom' },
|
||||
{ value: 'au', label: 'Australia' },
|
||||
{ value: 'de', label: 'Germany' },
|
||||
{ value: 'fr', label: 'France' }
|
||||
];
|
||||
|
||||
const interestOptions: CheckboxGroupOption[] = [
|
||||
{ value: 'technology', label: 'Technology' },
|
||||
{ value: 'sports', label: 'Sports' },
|
||||
{ value: 'music', label: 'Music' },
|
||||
{ value: 'travel', label: 'Travel' },
|
||||
{ value: 'cooking', label: 'Cooking' },
|
||||
{ value: 'reading', label: 'Reading' }
|
||||
];
|
||||
|
||||
const genderOptions: RadioGroupOption[] = [
|
||||
{ value: 'male', label: 'Male' },
|
||||
{ value: 'female', label: 'Female' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
{ value: 'prefer-not-to-say', label: 'Prefer not to say' }
|
||||
];
|
||||
|
||||
const fileUploadConfig: FileUploadConfig = {
|
||||
maxSize: 5000000, // 5MB
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
multiple: false,
|
||||
maxFiles: 1
|
||||
};
|
||||
|
||||
export default function DemoForm() {
|
||||
const form = useForm<DemoFormData>({
|
||||
resolver: zodResolver(demoFormSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
age: 18,
|
||||
password: '',
|
||||
bio: '',
|
||||
country: '',
|
||||
interests: [],
|
||||
gender: '',
|
||||
newsletter: false,
|
||||
rating: 5,
|
||||
birthDate: undefined,
|
||||
terms: false,
|
||||
avatar: []
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = (data: DemoFormData) => {
|
||||
console.log('Form submitted:', data);
|
||||
alert('Form submitted successfully! Check console for data.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-2xl space-y-6 p-6'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-2xl font-bold'>
|
||||
Reusable Form Components Demo
|
||||
</CardTitle>
|
||||
<p className='text-muted-foreground'>
|
||||
See how these components reduce boilerplate from 15+ lines to just
|
||||
5-8 lines per field
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
|
||||
{/* Basic Inputs */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='name'
|
||||
label='Full Name'
|
||||
placeholder='Enter your full name'
|
||||
required
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='email'
|
||||
type='email'
|
||||
label='Email Address'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='age'
|
||||
type='number'
|
||||
label='Age'
|
||||
min={18}
|
||||
max={100}
|
||||
required
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
control={form.control}
|
||||
name='password'
|
||||
type='password'
|
||||
label='Password'
|
||||
placeholder='Enter your password'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<FormTextarea
|
||||
control={form.control}
|
||||
name='bio'
|
||||
label='Bio'
|
||||
placeholder='Tell us about yourself...'
|
||||
description='A brief description about yourself'
|
||||
config={{
|
||||
maxLength: 500,
|
||||
showCharCount: true,
|
||||
rows: 4
|
||||
}}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Select */}
|
||||
<FormSelect
|
||||
control={form.control}
|
||||
name='country'
|
||||
label='Country'
|
||||
placeholder='Select your country'
|
||||
options={countryOptions}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Checkbox Group */}
|
||||
<FormCheckboxGroup
|
||||
control={form.control}
|
||||
name='interests'
|
||||
label='Interests'
|
||||
description='Select all that apply'
|
||||
options={interestOptions}
|
||||
columns={3}
|
||||
showBadges={true}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Radio Group */}
|
||||
<FormRadioGroup
|
||||
control={form.control}
|
||||
name='gender'
|
||||
label='Gender'
|
||||
options={genderOptions}
|
||||
orientation='horizontal'
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Switch */}
|
||||
<FormSwitch
|
||||
control={form.control}
|
||||
name='newsletter'
|
||||
label='Subscribe to Newsletter'
|
||||
description='Receive updates about new features and products'
|
||||
/>
|
||||
|
||||
{/* Slider */}
|
||||
<FormSlider
|
||||
control={form.control}
|
||||
name='rating'
|
||||
label='Overall Rating'
|
||||
description='Rate your experience (0-10)'
|
||||
config={{
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 0.5,
|
||||
formatValue: (value) => `${value}/10`
|
||||
}}
|
||||
showValue={true}
|
||||
/>
|
||||
|
||||
{/* Date Picker */}
|
||||
<FormDatePicker
|
||||
control={form.control}
|
||||
name='birthDate'
|
||||
label='Birth Date'
|
||||
description='Your date of birth (optional)'
|
||||
config={{
|
||||
maxDate: new Date(),
|
||||
placeholder: 'Select your birth date'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Single Checkbox */}
|
||||
<FormCheckbox
|
||||
control={form.control}
|
||||
name='terms'
|
||||
checkboxLabel='I agree to the Terms and Conditions'
|
||||
description='Please read and accept our terms'
|
||||
required
|
||||
/>
|
||||
|
||||
{/* File Upload */}
|
||||
<FormFileUpload
|
||||
control={form.control}
|
||||
name='avatar'
|
||||
label='Profile Picture'
|
||||
description='Upload a profile picture (optional)'
|
||||
config={fileUploadConfig}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className='flex gap-4 pt-4'>
|
||||
<Button type='submit' className='flex-1'>
|
||||
Submit Form
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => form.reset()}
|
||||
className='flex-1'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Data Preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Form Data Preview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className='bg-muted overflow-auto rounded-lg p-4 text-sm'>
|
||||
{JSON.stringify(form.watch(), null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/forms/fields/checkbox-field.tsx
Normal file
46
src/components/forms/fields/checkbox-field.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormField,
|
||||
FormFieldError,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function CheckboxField({ label, description }: CheckboxFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
|
||||
const isValid = useStore(field.store, (s) => s.meta.isValid);
|
||||
const value = useStore(field.store, (s) => s.value) as boolean;
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FormField orientation='horizontal'>
|
||||
<Checkbox
|
||||
checked={value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.handleChange(checked as boolean);
|
||||
field.handleBlur();
|
||||
}}
|
||||
aria-invalid={isTouched && !isValid}
|
||||
/>
|
||||
<div className='flex flex-1 flex-col gap-1.5 leading-snug'>
|
||||
<FieldLabel className='leading-none'>{label}</FieldLabel>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
<FormFieldError />
|
||||
</div>
|
||||
</FormField>
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormCheckboxField = createFormField(CheckboxField);
|
||||
54
src/components/forms/fields/file-upload-field.tsx
Normal file
54
src/components/forms/fields/file-upload-field.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { FileUploader } from '@/components/file-uploader';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormField,
|
||||
FormFieldError,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
|
||||
interface FileUploadFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
maxSize?: number;
|
||||
maxFiles?: number;
|
||||
}
|
||||
|
||||
export function FileUploadField({
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
maxSize,
|
||||
maxFiles
|
||||
}: FileUploadFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const value = useStore(field.store, (s) => s.value) as File[] | undefined;
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FormField>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
</FieldLabel>
|
||||
<div onBlur={field.handleBlur}>
|
||||
<FileUploader
|
||||
value={value}
|
||||
onValueChange={field.handleChange}
|
||||
maxSize={maxSize}
|
||||
maxFiles={maxFiles}
|
||||
/>
|
||||
</div>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</FormField>
|
||||
<FormFieldError />
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormFileUploadField = createFormField(FileUploadField);
|
||||
19
src/components/forms/fields/index.tsx
Normal file
19
src/components/forms/fields/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
// Base (for AppField/field group render props)
|
||||
export { TextField } from './text-field';
|
||||
export { TextareaField } from './textarea-field';
|
||||
export { SelectField } from './select-field';
|
||||
export { CheckboxField } from './checkbox-field';
|
||||
export { SwitchField } from './switch-field';
|
||||
export { RadioGroupField } from './radio-group-field';
|
||||
export { SliderField } from './slider-field';
|
||||
export { FileUploadField } from './file-upload-field';
|
||||
|
||||
// Composed (standalone, for direct use in forms)
|
||||
export { FormTextField } from './text-field';
|
||||
export { FormTextareaField } from './textarea-field';
|
||||
export { FormSelectField } from './select-field';
|
||||
export { FormCheckboxField } from './checkbox-field';
|
||||
export { FormSwitchField } from './switch-field';
|
||||
export { FormRadioGroupField } from './radio-group-field';
|
||||
export { FormSliderField } from './slider-field';
|
||||
export { FormFileUploadField } from './file-upload-field';
|
||||
52
src/components/forms/fields/radio-group-field.tsx
Normal file
52
src/components/forms/fields/radio-group-field.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormFieldError,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
interface RadioGroupFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
export function RadioGroupField({ label, description, required, options }: RadioGroupFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const value = useStore(field.store, (s) => s.value) as string;
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FieldLabel>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
</FieldLabel>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
<RadioGroup
|
||||
value={value}
|
||||
onValueChange={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
className='flex flex-wrap gap-x-6 gap-y-2'
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<div key={opt.value} className='flex items-center space-x-2'>
|
||||
<RadioGroupItem value={opt.value} id={`${field.name}-${opt.value}`} />
|
||||
<Label htmlFor={`${field.name}-${opt.value}`}>{opt.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<FormFieldError />
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormRadioGroupField = createFormField(RadioGroupField);
|
||||
74
src/components/forms/fields/select-field.tsx
Normal file
74
src/components/forms/fields/select-field.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormField,
|
||||
FormFieldError,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
interface SelectFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
placeholder = 'Select an option'
|
||||
}: SelectFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
|
||||
const isValid = useStore(field.store, (s) => s.meta.isValid);
|
||||
const value = useStore(field.store, (s) => s.value) as string;
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FormField>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={field.handleChange}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) field.handleBlur();
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={field.name} aria-invalid={isTouched && !isValid}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</FormField>
|
||||
<FormFieldError />
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormSelectField = createFormField(SelectField);
|
||||
58
src/components/forms/fields/slider-field.tsx
Normal file
58
src/components/forms/fields/slider-field.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormField,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
|
||||
interface SliderFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export function SliderField({
|
||||
label,
|
||||
description,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1
|
||||
}: SliderFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const value = (useStore(field.store, (s) => s.value) as number) ?? min;
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FormField>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
<div className='px-1'>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={(v) => field.handleChange(v[0])}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
<div className='text-muted-foreground mt-1 flex justify-between text-xs tabular-nums'>
|
||||
<span>{min}</span>
|
||||
<span className='font-medium'>
|
||||
{value}/{max}
|
||||
</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</FormField>
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormSliderField = createFormField(SliderField);
|
||||
35
src/components/forms/fields/switch-field.tsx
Normal file
35
src/components/forms/fields/switch-field.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormField,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
|
||||
interface SwitchFieldProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function SwitchField({ label, description }: SwitchFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const value = useStore(field.store, (s) => s.value) as boolean;
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FormField orientation='horizontal'>
|
||||
<div className='flex flex-1 flex-col gap-1.5 leading-snug'>
|
||||
<FieldLabel className='text-base'>{label}</FieldLabel>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</div>
|
||||
<Switch checked={value} onCheckedChange={field.handleChange} onBlur={field.handleBlur} />
|
||||
</FormField>
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormSwitchField = createFormField(SwitchField);
|
||||
77
src/components/forms/fields/text-field.tsx
Normal file
77
src/components/forms/fields/text-field.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormField,
|
||||
FormFieldError,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
interface TextFieldProps extends Omit<
|
||||
React.ComponentProps<'input'>,
|
||||
'value' | 'onChange' | 'onBlur'
|
||||
> {
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
type?: 'text' | 'email' | 'password' | 'tel' | 'url' | 'number';
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
type = 'text',
|
||||
className,
|
||||
...inputProps
|
||||
}: TextFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
|
||||
const isValid = useStore(field.store, (s) => s.meta.isValid);
|
||||
const isValidating = useStore(field.store, (s) => s.meta.isValidating);
|
||||
const value = useStore(field.store, (s) => s.value) as string | number;
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FormField>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
</FieldLabel>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id={field.name}
|
||||
type={type}
|
||||
value={value ?? ''}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
if (type === 'number') {
|
||||
const v = e.target.value;
|
||||
field.handleChange(v === '' ? '' : parseFloat(v));
|
||||
} else {
|
||||
field.handleChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
aria-invalid={isTouched && !isValid}
|
||||
className={className}
|
||||
{...inputProps}
|
||||
/>
|
||||
{isValidating && (
|
||||
<div className='absolute top-1/2 right-3 -translate-y-1/2'>
|
||||
<Spinner className='h-4 w-4' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</FormField>
|
||||
<FormFieldError />
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormTextField = createFormField(TextField);
|
||||
69
src/components/forms/fields/textarea-field.tsx
Normal file
69
src/components/forms/fields/textarea-field.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@tanstack/react-form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FieldDescription, FieldLabel } from '@/components/ui/field';
|
||||
import {
|
||||
useFieldContext,
|
||||
FormFieldSet,
|
||||
FormField,
|
||||
FormFieldError,
|
||||
createFormField
|
||||
} from '@/components/ui/form-context';
|
||||
|
||||
interface TextareaFieldProps extends Omit<
|
||||
React.ComponentProps<'textarea'>,
|
||||
'value' | 'onChange' | 'onBlur'
|
||||
> {
|
||||
label: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
showCount?: boolean;
|
||||
}
|
||||
|
||||
export function TextareaField({
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
maxLength,
|
||||
showCount = !!maxLength,
|
||||
className,
|
||||
...textareaProps
|
||||
}: TextareaFieldProps) {
|
||||
const field = useFieldContext();
|
||||
const isTouched = useStore(field.store, (s) => s.meta.isTouched);
|
||||
const isValid = useStore(field.store, (s) => s.meta.isValid);
|
||||
const value = (useStore(field.store, (s) => s.value) as string) ?? '';
|
||||
|
||||
return (
|
||||
<FormFieldSet>
|
||||
<FormField>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{label}
|
||||
{required && ' *'}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id={field.name}
|
||||
value={value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
maxLength={maxLength}
|
||||
aria-invalid={isTouched && !isValid}
|
||||
className={className}
|
||||
{...textareaProps}
|
||||
/>
|
||||
{showCount && (
|
||||
<div className='text-muted-foreground text-right text-xs tabular-nums'>
|
||||
{value.length}
|
||||
{maxLength ? ` / ${maxLength}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{description && <FieldDescription>{description}</FieldDescription>}
|
||||
</FormField>
|
||||
<FormFieldError />
|
||||
</FormFieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export const FormTextareaField = createFormField(TextareaField);
|
||||
110
src/components/forms/form-checkbox-group.tsx
Normal file
110
src/components/forms/form-checkbox-group.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BaseFormFieldProps, CheckboxGroupOption } from '@/types/base-form';
|
||||
|
||||
interface FormCheckboxGroupProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
options: CheckboxGroupOption[];
|
||||
showBadges?: boolean;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
}
|
||||
|
||||
function FormCheckboxGroup<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
showBadges = true,
|
||||
columns = 2,
|
||||
disabled,
|
||||
className
|
||||
}: FormCheckboxGroupProps<TFieldValues, TName>) {
|
||||
const gridCols = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<div className={`grid gap-4 ${gridCols[columns]}`}>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className='flex items-center space-x-2'>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
id={`${name}-${option.value}`}
|
||||
checked={field.value?.includes(option.value) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentValues = field.value || [];
|
||||
if (checked) {
|
||||
field.onChange([...currentValues, option.value]);
|
||||
} else {
|
||||
field.onChange(
|
||||
currentValues.filter(
|
||||
(value: string) => value !== option.value
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || option.disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<label
|
||||
htmlFor={`${name}-${option.value}`}
|
||||
className='text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showBadges && field.value && field.value.length > 0 && (
|
||||
<div className='mt-2 flex flex-wrap gap-2'>
|
||||
{field.value.map((value: string) => {
|
||||
const option = options.find((opt) => opt.value === value);
|
||||
return (
|
||||
<Badge key={value} variant='secondary'>
|
||||
{option?.label || value}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormCheckboxGroup, type CheckboxGroupOption };
|
||||
64
src/components/forms/form-checkbox.tsx
Normal file
64
src/components/forms/form-checkbox.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { BaseFormFieldProps } from '@/types/base-form';
|
||||
|
||||
interface FormCheckboxProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
checkboxLabel?: string;
|
||||
}
|
||||
|
||||
function FormCheckbox<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
checkboxLabel,
|
||||
disabled,
|
||||
className
|
||||
}: FormCheckboxProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={`flex flex-row items-start space-y-0 space-x-3 ${className}`}
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className='space-y-1 leading-none'>
|
||||
<FormLabel>
|
||||
{checkboxLabel || label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormCheckbox };
|
||||
105
src/components/forms/form-date-picker.tsx
Normal file
105
src/components/forms/form-date-picker.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from '@/components/ui/popover';
|
||||
import { BaseFormFieldProps, DatePickerConfig } from '@/types/base-form';
|
||||
|
||||
interface FormDatePickerProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
config?: DatePickerConfig;
|
||||
}
|
||||
|
||||
function FormDatePicker<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
config = {},
|
||||
disabled,
|
||||
className
|
||||
}: FormDatePickerProps<TFieldValues, TName>) {
|
||||
const {
|
||||
minDate,
|
||||
maxDate,
|
||||
disabledDates = [],
|
||||
placeholder = 'Pick a date'
|
||||
} = config;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={`flex flex-col ${className}`}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant='outline'
|
||||
className={`w-full pl-3 text-left font-normal ${
|
||||
!field.value && 'text-muted-foreground'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, 'PPP')
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date) => {
|
||||
if (minDate && date < minDate) return true;
|
||||
if (maxDate && date > maxDate) return true;
|
||||
return disabledDates.some(
|
||||
(disabledDate) => date.getTime() === disabledDate.getTime()
|
||||
);
|
||||
}}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormDatePicker };
|
||||
84
src/components/forms/form-file-upload.tsx
Normal file
84
src/components/forms/form-file-upload.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { BaseFormFieldProps, FileUploadConfig } from '@/types/base-form';
|
||||
import { FileUploader, FileUploaderProps } from '@/components/file-uploader';
|
||||
|
||||
interface FormFileUploadProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
config?: FileUploadConfig;
|
||||
}
|
||||
|
||||
function FormFileUpload<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
config,
|
||||
disabled,
|
||||
className
|
||||
}: FormFileUploadProps<TFieldValues, TName>) {
|
||||
const {
|
||||
maxSize,
|
||||
acceptedTypes,
|
||||
multiple,
|
||||
maxFiles,
|
||||
onUpload,
|
||||
progresses,
|
||||
...restConfig
|
||||
} = config || {};
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
|
||||
<FormControl>
|
||||
<FileUploader
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onUpload={onUpload}
|
||||
progresses={progresses}
|
||||
accept={acceptedTypes?.reduce(
|
||||
(acc, type) => ({ ...acc, [type]: [] }),
|
||||
{}
|
||||
)}
|
||||
maxSize={maxSize}
|
||||
maxFiles={maxFiles}
|
||||
multiple={multiple}
|
||||
disabled={disabled}
|
||||
{...restConfig}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormFileUpload, type FileUploadConfig };
|
||||
82
src/components/forms/form-input.tsx
Normal file
82
src/components/forms/form-input.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { BaseFormFieldProps } from '@/types/base-form';
|
||||
|
||||
interface FormInputProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
||||
placeholder?: string;
|
||||
step?: string | number;
|
||||
min?: string | number;
|
||||
max?: string | number;
|
||||
}
|
||||
|
||||
function FormInput<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
step,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
className
|
||||
}: FormInputProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
<Input
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
if (type === 'number') {
|
||||
const value = e.target.value;
|
||||
field.onChange(value === '' ? undefined : parseFloat(value));
|
||||
} else {
|
||||
field.onChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormInput };
|
||||
86
src/components/forms/form-radio-group.tsx
Normal file
86
src/components/forms/form-radio-group.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BaseFormFieldProps, RadioGroupOption } from '@/types/base-form';
|
||||
|
||||
interface FormRadioGroupProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
options: RadioGroupOption[];
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
function FormRadioGroup<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
orientation = 'vertical',
|
||||
disabled,
|
||||
className
|
||||
}: FormRadioGroupProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={disabled}
|
||||
className={
|
||||
orientation === 'horizontal'
|
||||
? 'flex flex-row space-x-6'
|
||||
: 'space-y-2'
|
||||
}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className='flex items-center space-x-2'>
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`${name}-${option.value}`}
|
||||
disabled={option.disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${name}-${option.value}`}
|
||||
className='text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormRadioGroup, type RadioGroupOption };
|
||||
86
src/components/forms/form-select.tsx
Normal file
86
src/components/forms/form-select.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { BaseFormFieldProps, FormOption } from '@/types/base-form';
|
||||
|
||||
interface FormSelectProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
options: FormOption[];
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
function FormSelect<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
options,
|
||||
placeholder = 'Select an option',
|
||||
disabled,
|
||||
className
|
||||
}: FormSelectProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormSelect, type FormOption };
|
||||
82
src/components/forms/form-slider.tsx
Normal file
82
src/components/forms/form-slider.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { BaseFormFieldProps, SliderConfig } from '@/types/base-form';
|
||||
|
||||
interface FormSliderProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
config: SliderConfig;
|
||||
showValue?: boolean;
|
||||
}
|
||||
|
||||
function FormSlider<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
config,
|
||||
showValue = true,
|
||||
disabled,
|
||||
className
|
||||
}: FormSliderProps<TFieldValues, TName>) {
|
||||
const { min, max, step = 1, formatValue } = config;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
<div className='px-3'>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={[field.value || min]}
|
||||
onValueChange={(value) => field.onChange(value[0])}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{showValue && (
|
||||
<div className='text-muted-foreground mt-1 flex justify-between text-sm'>
|
||||
<span>{formatValue ? formatValue(min) : min}</span>
|
||||
<span>
|
||||
{formatValue
|
||||
? formatValue(field.value || min)
|
||||
: field.value || min}
|
||||
</span>
|
||||
<span>{formatValue ? formatValue(max) : max}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormSlider };
|
||||
65
src/components/forms/form-switch.tsx
Normal file
65
src/components/forms/form-switch.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { BaseFormFieldProps } from '@/types/base-form';
|
||||
|
||||
interface FormSwitchProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
function FormSwitch<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
showDescription = true,
|
||||
disabled,
|
||||
className
|
||||
}: FormSwitchProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem
|
||||
className={`flex flex-row items-center justify-between rounded-lg border p-4 ${className}`}
|
||||
>
|
||||
<div className='space-y-0.5'>
|
||||
<FormLabel className='text-base'>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
{showDescription && description && (
|
||||
<FormDescription>{description}</FormDescription>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormSwitch };
|
||||
81
src/components/forms/form-textarea.tsx
Normal file
81
src/components/forms/form-textarea.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { FieldPath, FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from '@/components/ui/form';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { BaseFormFieldProps, TextareaConfig } from '@/types/base-form';
|
||||
|
||||
interface FormTextareaProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> extends BaseFormFieldProps<TFieldValues, TName> {
|
||||
placeholder?: string;
|
||||
config?: TextareaConfig;
|
||||
}
|
||||
|
||||
function FormTextarea<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
placeholder,
|
||||
config = {},
|
||||
disabled,
|
||||
className
|
||||
}: FormTextareaProps<TFieldValues, TName>) {
|
||||
const {
|
||||
maxLength,
|
||||
showCharCount = true,
|
||||
rows = 4,
|
||||
resize = 'vertical'
|
||||
} = config;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={className}>
|
||||
{label && (
|
||||
<FormLabel>
|
||||
{label}
|
||||
{required && <span className='ml-1 text-red-500'>*</span>}
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
<div className='space-y-2'>
|
||||
<Textarea
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={rows}
|
||||
style={{ resize }}
|
||||
maxLength={maxLength}
|
||||
{...field}
|
||||
/>
|
||||
{showCharCount && maxLength && (
|
||||
<div className='text-muted-foreground text-right text-sm'>
|
||||
{field.value?.length || 0} / {maxLength}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormTextarea };
|
||||
150
src/components/github-stars-button.tsx
Normal file
150
src/components/github-stars-button.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ─── GitHub API helpers (self-contained) ─────────────────────────────────────
|
||||
|
||||
interface GitHubRepo {
|
||||
fullName: string;
|
||||
stars: number;
|
||||
}
|
||||
|
||||
async function fetchGitHubRepo(owner: string, repo: string): Promise<GitHubRepo | null> {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
next: { revalidate: 3600 }
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
if (typeof data.full_name !== 'string' || typeof data.stargazers_count !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fullName: data.full_name,
|
||||
stars: data.stargazers_count
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCount(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
const value = count / 1_000_000;
|
||||
return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}m`;
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
const value = count / 1_000;
|
||||
return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}k`;
|
||||
}
|
||||
return count.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
type IconStyle = 'currentColor' | 'github' | 'copilot' | 'muted';
|
||||
|
||||
function GitHubIcon({
|
||||
iconStyle = 'currentColor',
|
||||
className
|
||||
}: {
|
||||
iconStyle?: IconStyle;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 16 16'
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
className,
|
||||
iconStyle === 'github' && 'text-[#0FBF3E]',
|
||||
iconStyle === 'copilot' && 'text-[#8534F3]',
|
||||
iconStyle === 'muted' && 'opacity-50 grayscale'
|
||||
)}
|
||||
fill='currentColor'
|
||||
>
|
||||
<path d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const githubStarsButtonVariants = cva(
|
||||
'inline-flex items-center shrink-0 whitespace-nowrap font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'rounded-md border border-border bg-muted/50 text-muted-foreground shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
primary: 'rounded-md bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
secondary:
|
||||
'rounded-md border border-transparent bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
outline:
|
||||
'rounded-md border border-border bg-background text-foreground shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
ghost:
|
||||
'rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
subtle:
|
||||
'rounded-full border border-border/60 bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-7 gap-1.5 px-2.5 text-xs [&_svg]:size-3.5',
|
||||
default: 'h-8 gap-2 px-3 text-sm [&_svg]:size-4',
|
||||
lg: 'h-9 gap-2.5 px-4 text-sm [&_svg]:size-4'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
interface GitHubStarsButtonProps
|
||||
extends
|
||||
Omit<React.ComponentProps<'a'>, 'children'>,
|
||||
VariantProps<typeof githubStarsButtonVariants> {
|
||||
owner: string;
|
||||
repo: string;
|
||||
stars?: number;
|
||||
showRepo?: boolean;
|
||||
iconStyle?: IconStyle;
|
||||
}
|
||||
|
||||
async function GitHubStarsButton({
|
||||
owner,
|
||||
repo,
|
||||
stars: starsProp,
|
||||
showRepo = false,
|
||||
iconStyle = 'currentColor',
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: GitHubStarsButtonProps) {
|
||||
const data = starsProp == null ? await fetchGitHubRepo(owner, repo) : null;
|
||||
const stars = starsProp ?? data?.stars ?? null;
|
||||
const fullName = data?.fullName ?? `${owner}/${repo}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://github.com/${owner}/${repo}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
data-slot='github-stars-button'
|
||||
aria-label={`${fullName} on GitHub${stars !== null ? ` — ${stars.toLocaleString('en-US')} stars` : ''}`}
|
||||
className={cn(githubStarsButtonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
>
|
||||
<GitHubIcon iconStyle={iconStyle} className='shrink-0' />
|
||||
{showRepo && <span>{fullName}</span>}
|
||||
{stars !== null && (
|
||||
<>
|
||||
{showRepo && <span className='bg-border h-3.5 w-px shrink-0' aria-hidden='true' />}
|
||||
<span className='tabular-nums'>{formatCount(stars)}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export { GitHubStarsButton, githubStarsButtonVariants, type GitHubStarsButtonProps };
|
||||
70
src/components/icons.tsx
Normal file
70
src/components/icons.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconCheck,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconCommand,
|
||||
IconCreditCard,
|
||||
IconFile,
|
||||
IconFileText,
|
||||
IconHelpCircle,
|
||||
IconPhoto,
|
||||
IconDeviceLaptop,
|
||||
IconLayoutDashboard,
|
||||
IconLoader2,
|
||||
IconLogin,
|
||||
IconProps,
|
||||
IconShoppingBag,
|
||||
IconMoon,
|
||||
IconDotsVertical,
|
||||
IconPizza,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconTrash,
|
||||
IconBrandTwitter,
|
||||
IconUser,
|
||||
IconUserCircle,
|
||||
IconUserEdit,
|
||||
IconUserX,
|
||||
IconX,
|
||||
IconLayoutKanban,
|
||||
IconBrandGithub
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
export type Icon = React.ComponentType<IconProps>;
|
||||
|
||||
export const Icons = {
|
||||
dashboard: IconLayoutDashboard,
|
||||
logo: IconCommand,
|
||||
login: IconLogin,
|
||||
close: IconX,
|
||||
product: IconShoppingBag,
|
||||
spinner: IconLoader2,
|
||||
kanban: IconLayoutKanban,
|
||||
chevronLeft: IconChevronLeft,
|
||||
chevronRight: IconChevronRight,
|
||||
trash: IconTrash,
|
||||
employee: IconUserX,
|
||||
post: IconFileText,
|
||||
page: IconFile,
|
||||
userPen: IconUserEdit,
|
||||
user2: IconUserCircle,
|
||||
media: IconPhoto,
|
||||
settings: IconSettings,
|
||||
billing: IconCreditCard,
|
||||
ellipsis: IconDotsVertical,
|
||||
add: IconPlus,
|
||||
warning: IconAlertTriangle,
|
||||
user: IconUser,
|
||||
arrowRight: IconArrowRight,
|
||||
help: IconHelpCircle,
|
||||
pizza: IconPizza,
|
||||
sun: IconSun,
|
||||
moon: IconMoon,
|
||||
laptop: IconDeviceLaptop,
|
||||
github: IconBrandGithub,
|
||||
twitter: IconBrandTwitter,
|
||||
check: IconCheck
|
||||
};
|
||||
13
src/components/layout/ThemeToggle/theme-provider.tsx
Normal file
13
src/components/layout/ThemeToggle/theme-provider.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ThemeProvider as NextThemesProvider,
|
||||
ThemeProviderProps
|
||||
} from 'next-themes';
|
||||
|
||||
export default function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
46
src/components/layout/ThemeToggle/theme-toggle.tsx
Normal file
46
src/components/layout/ThemeToggle/theme-toggle.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { IconBrightness } from '@tabler/icons-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const handleThemeToggle = React.useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
const newMode = resolvedTheme === 'dark' ? 'light' : 'dark';
|
||||
const root = document.documentElement;
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
setTheme(newMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set coordinates from the click event
|
||||
if (e) {
|
||||
root.style.setProperty('--x', `${e.clientX}px`);
|
||||
root.style.setProperty('--y', `${e.clientY}px`);
|
||||
}
|
||||
|
||||
document.startViewTransition(() => {
|
||||
setTheme(newMode);
|
||||
});
|
||||
},
|
||||
[resolvedTheme, setTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
className='group/toggle size-8'
|
||||
onClick={handleThemeToggle}
|
||||
>
|
||||
<IconBrightness />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
213
src/components/layout/app-sidebar.tsx
Normal file
213
src/components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail
|
||||
} from '@/components/ui/sidebar';
|
||||
import { UserAvatarProfile } from '@/components/user-avatar-profile';
|
||||
import { navItems } from '@/constants/data';
|
||||
import { useMediaQuery } from '@/hooks/use-media-query';
|
||||
import { useUser } from '@clerk/nextjs';
|
||||
import {
|
||||
IconBell,
|
||||
IconChevronRight,
|
||||
IconChevronsDown,
|
||||
IconCreditCard,
|
||||
IconLogout,
|
||||
IconPhotoUp,
|
||||
IconUserCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { SignOutButton } from '@clerk/nextjs';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
import { Icons } from '../icons';
|
||||
import { OrgSwitcher } from '../org-switcher';
|
||||
export const company = {
|
||||
name: 'Acme Inc',
|
||||
logo: IconPhotoUp,
|
||||
plan: 'Enterprise'
|
||||
};
|
||||
|
||||
const tenants = [
|
||||
{ id: '1', name: 'Acme Inc' },
|
||||
{ id: '2', name: 'Beta Corp' },
|
||||
{ id: '3', name: 'Gamma Ltd' }
|
||||
];
|
||||
|
||||
export default function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { isOpen } = useMediaQuery();
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
const handleSwitchTenant = (_tenantId: string) => {
|
||||
// Tenant switching functionality would be implemented here
|
||||
};
|
||||
|
||||
const activeTenant = tenants[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
// Side effects based on sidebar state changes
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible='icon'>
|
||||
<SidebarHeader>
|
||||
<OrgSwitcher
|
||||
tenants={tenants}
|
||||
defaultTenant={activeTenant}
|
||||
onTenantSwitch={handleSwitchTenant}
|
||||
/>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className='overflow-x-hidden'>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Overview</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
||||
return item?.items && item?.items?.length > 0 ? (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
{item.icon && <Icon />}
|
||||
<span>{item.title}</span>
|
||||
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === subItem.url}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<Icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
{user && (
|
||||
<UserAvatarProfile
|
||||
className='h-8 w-8 rounded-lg'
|
||||
showInfo
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
<IconChevronsDown className='ml-auto size-4' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='p-0 font-normal'>
|
||||
<div className='px-1 py-1.5'>
|
||||
{user && (
|
||||
<UserAvatarProfile
|
||||
className='h-8 w-8 rounded-lg'
|
||||
showInfo
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push('/dashboard/profile')}
|
||||
>
|
||||
<IconUserCircle className='mr-2 h-4 w-4' />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard className='mr-2 h-4 w-4' />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconBell className='mr-2 h-4 w-4' />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
<SignOutButton redirectUrl='/auth/sign-in' />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
18
src/components/layout/cta-github.tsx
Normal file
18
src/components/layout/cta-github.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { IconBrandGithub } from '@tabler/icons-react';
|
||||
|
||||
export default function CtaGithub() {
|
||||
return (
|
||||
<Button variant='ghost' asChild size='sm' className='hidden sm:flex'>
|
||||
<a
|
||||
href='https://github.com/Kiranism/next-shadcn-dashboard-starter'
|
||||
rel='noopener noreferrer'
|
||||
target='_blank'
|
||||
className='dark:text-foreground'
|
||||
>
|
||||
<IconBrandGithub />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
31
src/components/layout/header.tsx
Normal file
31
src/components/layout/header.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { SidebarTrigger } from '../ui/sidebar';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Breadcrumbs } from '../breadcrumbs';
|
||||
import SearchInput from '../search-input';
|
||||
import { UserNav } from './user-nav';
|
||||
import { ThemeSelector } from '../theme-selector';
|
||||
import { ModeToggle } from './ThemeToggle/theme-toggle';
|
||||
import CtaGithub from './cta-github';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className='flex h-16 shrink-0 items-center justify-between gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12'>
|
||||
<div className='flex items-center gap-2 px-4'>
|
||||
<SidebarTrigger className='-ml-1' />
|
||||
<Separator orientation='vertical' className='mr-2 h-4' />
|
||||
<Breadcrumbs />
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2 px-4'>
|
||||
<CtaGithub />
|
||||
<div className='hidden md:flex'>
|
||||
<SearchInput />
|
||||
</div>
|
||||
<UserNav />
|
||||
<ModeToggle />
|
||||
<ThemeSelector />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
99
src/components/layout/info-sidebar.tsx
Normal file
99
src/components/layout/info-sidebar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Icons } from '@/components/icons';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Infobar,
|
||||
InfobarContent,
|
||||
InfobarGroup,
|
||||
InfobarGroupContent,
|
||||
InfobarHeader,
|
||||
InfobarRail,
|
||||
InfobarTrigger,
|
||||
useInfobar
|
||||
} from '@/components/ui/infobar';
|
||||
|
||||
// Default/fallback data when no content is set
|
||||
const defaultData = {
|
||||
title: 'Documentation',
|
||||
sections: [
|
||||
{
|
||||
title: 'Getting Started',
|
||||
description: 'Learn how to get started with this application.',
|
||||
links: [
|
||||
{
|
||||
title: 'Installation Guide',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function InfoSidebar({ ...props }: React.ComponentProps<typeof Infobar>) {
|
||||
const { content } = useInfobar();
|
||||
const data = content || defaultData;
|
||||
|
||||
return (
|
||||
<Infobar {...props}>
|
||||
<InfobarHeader className='bg-sidebar sticky top-0 z-10 flex flex-row items-start justify-between gap-2 border-b px-3 py-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h2 className='text-lg font-semibold wrap-break-word'>{data.title}</h2>
|
||||
</div>
|
||||
<div className='shrink-0'>
|
||||
<InfobarTrigger className='-mr-1' />
|
||||
</div>
|
||||
</InfobarHeader>
|
||||
<InfobarContent>
|
||||
<InfobarGroup>
|
||||
<InfobarGroupContent>
|
||||
<div className='flex flex-col gap-6 px-4 py-4'>
|
||||
{data.sections && data.sections.length > 0 ? (
|
||||
data.sections.map((section) => (
|
||||
<div key={section.title} className='flex flex-col gap-3'>
|
||||
{section.title && (
|
||||
<h3 className='text-foreground text-sm font-semibold'>{section.title}</h3>
|
||||
)}
|
||||
{section.description && (
|
||||
<p className='text-muted-foreground text-sm leading-relaxed'>
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
{section.links && section.links.length > 0 && (
|
||||
<div className='flex flex-col gap-2'>
|
||||
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
Learn more
|
||||
</h4>
|
||||
<ul className='flex flex-col gap-1.5'>
|
||||
{section.links.map((link) => (
|
||||
<li key={link.title}>
|
||||
<Link
|
||||
href={link.url}
|
||||
className='text-primary flex items-center gap-1.5 text-sm underline'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<span>{link.title}</span>
|
||||
<Icons.chevronRight className='h-3 w-3' />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className='text-muted-foreground px-2 py-4 text-center text-sm'>
|
||||
No content available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InfobarGroupContent>
|
||||
</InfobarGroup>
|
||||
</InfobarContent>
|
||||
<InfobarRail />
|
||||
</Infobar>
|
||||
);
|
||||
}
|
||||
22
src/components/layout/page-container.tsx
Normal file
22
src/components/layout/page-container.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
export default function PageContainer({
|
||||
children,
|
||||
scrollable = true
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{scrollable ? (
|
||||
<ScrollArea className='h-[calc(100dvh-52px)]'>
|
||||
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className='flex flex-1 p-4 md:px-6'>{children}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/components/layout/providers.tsx
Normal file
31
src/components/layout/providers.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
import { ClerkProvider } from '@clerk/nextjs';
|
||||
import { dark } from '@clerk/themes';
|
||||
import { useTheme } from 'next-themes';
|
||||
import React from 'react';
|
||||
import { ActiveThemeProvider } from '../active-theme';
|
||||
|
||||
export default function Providers({
|
||||
activeThemeValue,
|
||||
children
|
||||
}: {
|
||||
activeThemeValue: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// we need the resolvedTheme value to set the baseTheme for clerk based on the dark or light theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActiveThemeProvider initialTheme={activeThemeValue}>
|
||||
<ClerkProvider
|
||||
appearance={{
|
||||
baseTheme: resolvedTheme === 'dark' ? dark : undefined
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ClerkProvider>
|
||||
</ActiveThemeProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
src/components/layout/query-provider.tsx
Normal file
17
src/components/layout/query-provider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import type * as React from 'react';
|
||||
|
||||
export default function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
59
src/components/layout/user-nav.tsx
Normal file
59
src/components/layout/user-nav.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { UserAvatarProfile } from '@/components/user-avatar-profile';
|
||||
import { SignOutButton, useUser } from '@clerk/nextjs';
|
||||
import { useRouter } from 'next/navigation';
|
||||
export function UserNav() {
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
if (user) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||
<UserAvatarProfile user={user} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-56'
|
||||
align='end'
|
||||
sideOffset={10}
|
||||
forceMount
|
||||
>
|
||||
<DropdownMenuLabel className='font-normal'>
|
||||
<div className='flex flex-col space-y-1'>
|
||||
<p className='text-sm leading-none font-medium'>
|
||||
{user.fullName}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-xs leading-none'>
|
||||
{user.emailAddresses[0].emailAddress}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => router.push('/dashboard/profile')}>
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>Billing</DropdownMenuItem>
|
||||
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||
<DropdownMenuItem>New Team</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<SignOutButton redirectUrl='/auth/sign-in' />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
46
src/components/modal/alert-modal.tsx
Normal file
46
src/components/modal/alert-modal.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modal } from '@/components/ui/modal';
|
||||
|
||||
interface AlertModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const AlertModal: React.FC<AlertModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
loading
|
||||
}) => {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title='Are you sure?'
|
||||
description='This action cannot be undone.'
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className='flex w-full items-center justify-end space-x-2 pt-6'>
|
||||
<Button disabled={loading} variant='outline' onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={loading} variant='destructive' onClick={onConfirm}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
80
src/components/nav-main.tsx
Normal file
80
src/components/nav-main.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Icon } from '@/components/icons';
|
||||
|
||||
export function NavMain({
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: Icon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarGroupContent className='flex flex-col gap-2'>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
className='bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear'
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<IconChevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
89
src/components/nav-projects.tsx
Normal file
89
src/components/nav-projects.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
IconFolder,
|
||||
IconShare,
|
||||
IconDots,
|
||||
IconTrash
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Icon } from '@/components/icons';
|
||||
|
||||
export function NavProjects({
|
||||
projects
|
||||
}: {
|
||||
projects: {
|
||||
name: string;
|
||||
url: string;
|
||||
icon: Icon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className='group-data-[collapsible=icon]:hidden'>
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<IconDots />
|
||||
<span className='sr-only'>More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-48 rounded-lg'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align={isMobile ? 'end' : 'start'}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<IconFolder className='text-muted-foreground mr-2 h-4 w-4' />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconShare className='text-muted-foreground mr-2 h-4 w-4' />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<IconTrash className='text-muted-foreground mr-2 h-4 w-4' />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className='text-sidebar-foreground/70'>
|
||||
<IconDots className='text-sidebar-foreground/70' />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
110
src/components/nav-user.tsx
Normal file
110
src/components/nav-user.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconBell,
|
||||
IconChevronsDown,
|
||||
IconCreditCard,
|
||||
IconLogout,
|
||||
IconSparkles
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
export function NavUser({
|
||||
user
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
<IconChevronsDown className='ml-auto size-4' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='p-0 font-normal'>
|
||||
<div className='flex items-center gap-2 px-1 py-1.5 text-left text-sm'>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconSparkles className='mr-2 h-4 w-4' />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconCircleCheck className='mr-2 h-4 w-4' />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconCreditCard className='mr-2 h-4 w-4' />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconBell className='mr-2 h-4 w-4' />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<IconLogout className='mr-2 h-4 w-4' />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
85
src/components/org-switcher.tsx
Normal file
85
src/components/org-switcher.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { Check, ChevronsUpDown, GalleryVerticalEnd } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem
|
||||
} from '@/components/ui/sidebar';
|
||||
|
||||
interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function OrgSwitcher({
|
||||
tenants,
|
||||
defaultTenant,
|
||||
onTenantSwitch
|
||||
}: {
|
||||
tenants: Tenant[];
|
||||
defaultTenant: Tenant;
|
||||
onTenantSwitch?: (tenantId: string) => void;
|
||||
}) {
|
||||
const [selectedTenant, setSelectedTenant] = React.useState<
|
||||
Tenant | undefined
|
||||
>(defaultTenant || (tenants.length > 0 ? tenants[0] : undefined));
|
||||
|
||||
const handleTenantSwitch = (tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
if (onTenantSwitch) {
|
||||
onTenantSwitch(tenant.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedTenant) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<div className='bg-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg'>
|
||||
<GalleryVerticalEnd className='size-4' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-0.5 leading-none'>
|
||||
<span className='font-semibold'>Next Starter</span>
|
||||
<span className=''>{selectedTenant.name}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ml-auto' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-[--radix-dropdown-menu-trigger-width]'
|
||||
align='start'
|
||||
>
|
||||
{tenants.map((tenant) => (
|
||||
<DropdownMenuItem
|
||||
key={tenant.id}
|
||||
onSelect={() => handleTenantSwitch(tenant)}
|
||||
>
|
||||
{tenant.name}{' '}
|
||||
{tenant.id === selectedTenant.id && (
|
||||
<Check className='ml-auto' />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
23
src/components/search-input.tsx
Normal file
23
src/components/search-input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
import { useKBar } from 'kbar';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export default function SearchInput() {
|
||||
const { query } = useKBar();
|
||||
return (
|
||||
<div className='w-full space-y-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='bg-background text-muted-foreground relative h-9 w-full justify-start rounded-[0.5rem] text-sm font-normal shadow-none sm:pr-12 md:w-40 lg:w-64'
|
||||
onClick={query.toggle}
|
||||
>
|
||||
<IconSearch className='mr-2 h-4 w-4' />
|
||||
Search...
|
||||
<kbd className='bg-muted pointer-events-none absolute top-[0.3rem] right-[0.3rem] hidden h-6 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 select-none sm:flex'>
|
||||
<span className='text-xs'>⌘</span>K
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/theme-selector.tsx
Normal file
102
src/components/theme-selector.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useThemeConfig } from '@/components/active-theme';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const DEFAULT_THEMES = [
|
||||
{
|
||||
name: 'Default',
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
value: 'blue'
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
value: 'green'
|
||||
},
|
||||
{
|
||||
name: 'Amber',
|
||||
value: 'amber'
|
||||
}
|
||||
];
|
||||
|
||||
const SCALED_THEMES = [
|
||||
{
|
||||
name: 'Default',
|
||||
value: 'default-scaled'
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
value: 'blue-scaled'
|
||||
}
|
||||
];
|
||||
|
||||
const MONO_THEMES = [
|
||||
{
|
||||
name: 'Mono',
|
||||
value: 'mono-scaled'
|
||||
}
|
||||
];
|
||||
|
||||
export function ThemeSelector() {
|
||||
const { activeTheme, setActiveTheme } = useThemeConfig();
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='theme-selector' className='sr-only'>
|
||||
Theme
|
||||
</Label>
|
||||
<Select value={activeTheme} onValueChange={setActiveTheme}>
|
||||
<SelectTrigger
|
||||
id='theme-selector'
|
||||
className='justify-start *:data-[slot=select-value]:w-12'
|
||||
>
|
||||
<span className='text-muted-foreground hidden sm:block'>
|
||||
Select a theme:
|
||||
</span>
|
||||
<span className='text-muted-foreground block sm:hidden'>Theme</span>
|
||||
<SelectValue placeholder='Select a theme' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='end'>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{DEFAULT_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Scaled</SelectLabel>
|
||||
{SCALED_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Monospaced</SelectLabel>
|
||||
{MONO_THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/components/themes/active-theme.tsx
Normal file
71
src/components/themes/active-theme.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode, createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { DEFAULT_THEME } from './theme.config';
|
||||
|
||||
const COOKIE_NAME = 'active_theme';
|
||||
|
||||
function setThemeCookie(theme: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
document.cookie = `${COOKIE_NAME}=${theme}; path=/; max-age=31536000; SameSite=Lax; ${window.location.protocol === 'https:' ? 'Secure;' : ''}`;
|
||||
}
|
||||
|
||||
type ThemeContextType = {
|
||||
activeTheme: string;
|
||||
setActiveTheme: (theme: string) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ActiveThemeProvider({
|
||||
children,
|
||||
initialTheme
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialTheme?: string;
|
||||
}) {
|
||||
const themeToUse = initialTheme || DEFAULT_THEME;
|
||||
const [activeTheme, setActiveTheme] = useState<string>(themeToUse);
|
||||
|
||||
useEffect(() => {
|
||||
// Only update if theme has changed
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (currentTheme !== activeTheme) {
|
||||
setThemeCookie(activeTheme);
|
||||
|
||||
// Remove existing data-theme attribute
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
|
||||
// Remove any theme classes from body (cleanup)
|
||||
Array.from(document.body.classList)
|
||||
.filter((className) => className.startsWith('theme-'))
|
||||
.forEach((className) => {
|
||||
document.body.classList.remove(className);
|
||||
});
|
||||
|
||||
// Set data-theme on html element
|
||||
if (activeTheme) {
|
||||
document.documentElement.setAttribute('data-theme', activeTheme);
|
||||
}
|
||||
} else {
|
||||
// Still update cookie in case it's missing
|
||||
setThemeCookie(activeTheme);
|
||||
}
|
||||
}, [activeTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ activeTheme, setActiveTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThemeConfig() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useThemeConfig must be used within an ActiveThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
108
src/components/themes/font.config.ts
Normal file
108
src/components/themes/font.config.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Architects_Daughter,
|
||||
DM_Sans,
|
||||
Fira_Code,
|
||||
Geist,
|
||||
Geist_Mono,
|
||||
Instrument_Sans,
|
||||
Inter,
|
||||
JetBrains_Mono,
|
||||
Merriweather,
|
||||
Mulish,
|
||||
Playfair_Display,
|
||||
Noto_Sans_Mono,
|
||||
Outfit,
|
||||
Space_Mono
|
||||
} from 'next/font/google';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const fontSans = Geist({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans'
|
||||
});
|
||||
|
||||
const fontMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono'
|
||||
});
|
||||
|
||||
const fontInstrument = Instrument_Sans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-instrument'
|
||||
});
|
||||
|
||||
const fontNotoMono = Noto_Sans_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-noto-mono'
|
||||
});
|
||||
|
||||
const fontMullish = Mulish({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mullish'
|
||||
});
|
||||
|
||||
const fontInter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-inter'
|
||||
});
|
||||
|
||||
const fontArchitectsDaughter = Architects_Daughter({
|
||||
subsets: ['latin'],
|
||||
weight: '400',
|
||||
variable: '--font-architects-daughter'
|
||||
});
|
||||
|
||||
const fontDMSans = DM_Sans({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-dm-sans'
|
||||
});
|
||||
|
||||
const fontFiraCode = Fira_Code({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-fira-code'
|
||||
});
|
||||
|
||||
const fontOutfit = Outfit({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-outfit'
|
||||
});
|
||||
|
||||
const fontSpaceMono = Space_Mono({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '700'],
|
||||
variable: '--font-space-mono'
|
||||
});
|
||||
|
||||
const fontJetBrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-jetbrains-mono'
|
||||
});
|
||||
|
||||
const fontMerriweather = Merriweather({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '700'],
|
||||
variable: '--font-merriweather'
|
||||
});
|
||||
|
||||
const fontPlayfairDisplay = Playfair_Display({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-playfair-display'
|
||||
});
|
||||
|
||||
export const fontVariables = cn(
|
||||
fontSans.variable,
|
||||
fontMono.variable,
|
||||
fontInstrument.variable,
|
||||
fontNotoMono.variable,
|
||||
fontMullish.variable,
|
||||
fontInter.variable,
|
||||
fontArchitectsDaughter.variable,
|
||||
fontDMSans.variable,
|
||||
fontFiraCode.variable,
|
||||
fontOutfit.variable,
|
||||
fontSpaceMono.variable,
|
||||
fontJetBrainsMono.variable,
|
||||
fontMerriweather.variable,
|
||||
fontPlayfairDisplay.variable
|
||||
);
|
||||
55
src/components/themes/theme-mode-toggle.tsx
Normal file
55
src/components/themes/theme-mode-toggle.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Icons } from '@/components/icons';
|
||||
import { useTheme } from 'next-themes';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
|
||||
export function ThemeModeToggle() {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const handleThemeToggle = React.useCallback(
|
||||
(e?: React.MouseEvent) => {
|
||||
const newMode = resolvedTheme === 'dark' ? 'light' : 'dark';
|
||||
const root = document.documentElement;
|
||||
|
||||
if (!document.startViewTransition) {
|
||||
setTheme(newMode);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set coordinates from the click event
|
||||
if (e) {
|
||||
root.style.setProperty('--x', `${e.clientX}px`);
|
||||
root.style.setProperty('--y', `${e.clientY}px`);
|
||||
}
|
||||
|
||||
document.startViewTransition(() => {
|
||||
setTheme(newMode);
|
||||
});
|
||||
},
|
||||
[resolvedTheme, setTheme]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='icon'
|
||||
className='group/toggle size-8'
|
||||
onClick={handleThemeToggle}
|
||||
>
|
||||
<Icons.brightness />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Toggle theme <Kbd>D D</Kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
7
src/components/themes/theme-provider.tsx
Normal file
7
src/components/themes/theme-provider.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ThemeProvider as NextThemesProvider, ThemeProviderProps } from 'next-themes';
|
||||
|
||||
export default function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
56
src/components/themes/theme-selector.tsx
Normal file
56
src/components/themes/theme-selector.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useThemeConfig } from '@/components/themes/active-theme';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
|
||||
import { Icons } from '../icons';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { THEMES } from './theme.config';
|
||||
|
||||
export function ThemeSelector() {
|
||||
const { activeTheme, setActiveTheme } = useThemeConfig();
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='theme-selector' className='sr-only'>
|
||||
Theme
|
||||
</Label>
|
||||
<Select value={activeTheme} onValueChange={setActiveTheme}>
|
||||
<SelectTrigger
|
||||
id='theme-selector'
|
||||
className='justify-start *:data-[slot=select-value]:w-24'
|
||||
>
|
||||
<span className='text-muted-foreground hidden sm:block'>
|
||||
<Icons.palette />
|
||||
</span>
|
||||
<span className='text-muted-foreground block sm:hidden'>Theme</span>
|
||||
<SelectValue placeholder='Select a theme' />
|
||||
<Kbd>T T</Kbd>
|
||||
</SelectTrigger>
|
||||
<SelectContent align='end'>
|
||||
{THEMES.length > 0 && (
|
||||
<>
|
||||
<SelectGroup>
|
||||
<SelectLabel>themes</SelectLabel>
|
||||
{THEMES.map((theme) => (
|
||||
<SelectItem key={theme.name} value={theme.value}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/themes/theme.config.ts
Normal file
48
src/components/themes/theme.config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Default theme that loads when no user preference is set
|
||||
* Change this value to set a different default theme
|
||||
*/
|
||||
export const DEFAULT_THEME = 'vercel';
|
||||
|
||||
export const THEMES = [
|
||||
{
|
||||
name: 'Claude',
|
||||
value: 'claude'
|
||||
},
|
||||
{
|
||||
name: 'Neobrutualism',
|
||||
value: 'neobrutualism'
|
||||
},
|
||||
{
|
||||
name: 'Supabase',
|
||||
value: 'supabase'
|
||||
},
|
||||
{
|
||||
name: 'Vercel',
|
||||
value: 'vercel'
|
||||
},
|
||||
{
|
||||
name: 'Mono',
|
||||
value: 'mono'
|
||||
},
|
||||
{
|
||||
name: 'Notebook',
|
||||
value: 'notebook'
|
||||
},
|
||||
{
|
||||
name: 'Light Green',
|
||||
value: 'light-green'
|
||||
},
|
||||
{
|
||||
name: 'Zen',
|
||||
value: 'zen'
|
||||
},
|
||||
{
|
||||
name: 'Astro Vista',
|
||||
value: 'astro-vista'
|
||||
},
|
||||
{
|
||||
name: 'WhatsApp',
|
||||
value: 'whatsapp'
|
||||
}
|
||||
];
|
||||
37
src/components/user-avatar-profile.tsx
Normal file
37
src/components/user-avatar-profile.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
|
||||
interface UserAvatarProfileProps {
|
||||
className?: string;
|
||||
showInfo?: boolean;
|
||||
user: {
|
||||
imageUrl?: string;
|
||||
fullName?: string | null;
|
||||
emailAddresses: Array<{ emailAddress: string }>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function UserAvatarProfile({
|
||||
className,
|
||||
showInfo = false,
|
||||
user
|
||||
}: UserAvatarProfileProps) {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={user?.imageUrl || ''} alt={user?.fullName || ''} />
|
||||
<AvatarFallback className='rounded-lg'>
|
||||
{user?.fullName?.slice(0, 2)?.toUpperCase() || 'CN'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{showInfo && (
|
||||
<div className='grid flex-1 text-left text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user?.fullName || ''}</span>
|
||||
<span className='truncate text-xs'>
|
||||
{user?.emailAddresses[0].emailAddress || ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user