diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..1023c19 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": "next/core-web-vitals", + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/no-unused-vars": "off", + "import/no-unresolved": "error", + "import/named": "off" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e9365e --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Serwist +public/sw* +public/swe-worker* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f395ecb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "semi": true, + "useTabs": false, + "trailingComma": "none", + "singleQuote": true, + "tabWidth": 2, + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c55ca90 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["cuid"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..73702d7 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ + + + + + +
Next.js 14 Admin Dashboard Starter Template With Shadcn-ui
+
Built with the Next.js App Router
+
+
+View Demo + +
+ +## Overview + +This is a starter template using the following stack: + +- Framework - [Next.js 14](https://nextjs.org/13) +- Language - [TypeScript](https://www.typescriptlang.org) +- Styling - [Tailwind CSS](https://tailwindcss.com) +- Components - [Shadcn-ui](https://ui.shadcn.com) +- Schema Validations - [Zod](https://zod.dev) +- State Management - [Zustand](https://zustand-demo.pmnd.rs) +- Search params state manager - [Nuqs](https://nuqs.47ng.com/) +- Auth - [Auth.js](https://authjs.dev/) +- Tables - [Tanstack Tables](https://ui.shadcn.com/docs/components/data-table) +- Forms - [React Hook Form](https://ui.shadcn.com/docs/components/form) +- Command+k interface - [kbar](https://kbar.vercel.app/) +- Linting - [ESLint](https://eslint.org) +- Pre-commit Hooks - [Husky](https://typicode.github.io/husky/) +- Formatting - [Prettier](https://prettier.io) + +_If you are looking for a React admin dashboard starter, here is the [repo](https://github.com/Kiranism/react-shadcn-dashboard-starter)._ + +## Pages + +| Pages | Specifications | +| :-------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | +| [Signup](https://next-shadcn-dashboard-starter.vercel.app/) | Authentication with **NextAuth** supports Social logins and email logins (Enter dummy email for demo). | +| [Dashboard](https://next-shadcn-dashboard-starter.vercel.app/app) | Cards with recharts graphs for analytics. | +| [Employee](https://next-shadcn-dashboard-starter.vercel.app/app/employee) | Tanstack tables with server side searching, filter, pagination by Nuqs which is a Type-safe search params state manager in nextjs). | +| [Employee/new](https://next-shadcn-dashboard-starter.vercel.app/app/employee/new) | A Employee Form with shadcn form (react-hook-form + zod). | +| [Product](https://next-shadcn-dashboard-starter.vercel.app/app/product) | Tanstack tables with server side searching, filter, pagination by Nuqs which is a Type-safe search params state manager in nextjs | +| [Product/new](https://next-shadcn-dashboard-starter.vercel.app/app/product/new) | A Product Form with shadcn form (react-hook-form + zod). | +| [Profile](https://next-shadcn-dashboard-starter.vercel.app/app/profile) | Mutistep dynamic forms using react-hook-form and zod for form validation. | +| [Kanban Board](https://next-shadcn-dashboard-starter.vercel.app/app/kanban) | A Drag n Drop task management board with dnd-kit and zustand to persist state locally. | +| [Not Found](https://next-shadcn-dashboard-starter.vercel.app/app/notfound) | Not Found Page Added in the root level | +| - | - | + +## Getting Started + +Follow these steps to clone the repository and start the development server: + +- `git clone https://github.com/Kiranism/next-shadcn-dashboard-starter.git` +- `npm install` +- Create a `.env.local` file by copying the example environment file: + `cp env.example.txt .env.local` +- Add the required environment variables to the `.env.local` file. +- `npm run dev` + +You should now be able to access the application at http://localhost:3000. + +> [!WARNING] +> After cloning or forking the repository, be cautious when pulling or syncing with the latest changes, as this may result in breaking conflicts. + +Cheers! 🥂 diff --git a/app/(auth)/(signin)/page.tsx b/app/(auth)/(signin)/page.tsx new file mode 100644 index 0000000..3a659f6 --- /dev/null +++ b/app/(auth)/(signin)/page.tsx @@ -0,0 +1,19 @@ +import { Metadata } from 'next'; +import GoogleSignInButton from '../_components/google-auth-button'; + +export const metadata: Metadata = { + title: 'Sign-in', + description: 'Sign-in with Google' +}; + +export default function SignInViewPage() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/app/(auth)/_components/google-auth-button.tsx b/app/(auth)/_components/google-auth-button.tsx new file mode 100644 index 0000000..35e6ee5 --- /dev/null +++ b/app/(auth)/_components/google-auth-button.tsx @@ -0,0 +1,20 @@ +'use client'; +import { useSearchParams } from 'next/navigation'; +import { signIn } from 'next-auth/react'; +import { Button } from '@/components/ui/button'; +import { DrumIcon } from 'lucide-react'; +import { Icons } from '@/components/icons'; +import { RainbowButton } from '@/components/ui/rainbow-button'; + +export default function GoogleSignInButton() { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl'); + return ( + signIn('google', { callbackUrl: callbackUrl ?? '/app' })} + > + + Sign-in with Google + + ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..0aa1cd7 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from '@/auth'; +export const { GET, POST } = handlers; diff --git a/app/app/layout.tsx b/app/app/layout.tsx new file mode 100644 index 0000000..717e027 --- /dev/null +++ b/app/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { cookies } from 'next/headers'; + +export const metadata: Metadata = { + title: 'Next-Shadcn-TWA', + description: + 'Trusted Web Activities Template built with Next.js, TailwindCSS and Shad-CN' +}; + +export default function DashboardLayout({ + children +}: { + children: React.ReactNode; +}) { + // Persisting the sidebar state in the cookie. + const cookieStore = cookies(); + const defaultOpen = cookieStore.get('sidebar:state')?.value === 'true'; + return <>{children}; +} diff --git a/app/app/loading.tsx b/app/app/loading.tsx new file mode 100644 index 0000000..91b45d0 --- /dev/null +++ b/app/app/loading.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Loader } from '@/components/ui/loader'; + +export default function Loading() { + return ( +
+
+ +
+
+ ); +} diff --git a/app/app/overview/page.tsx b/app/app/overview/page.tsx new file mode 100644 index 0000000..049ff8c --- /dev/null +++ b/app/app/overview/page.tsx @@ -0,0 +1,23 @@ +import PageContainer from '@/components/layout/page-container'; + +import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; +import AuthInfo from '@/components/layout/auth-info'; + +export default async function OverViewPage() { + const session = await auth(); + + if (!session?.user) { + redirect('/signin'); + } + + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 0000000..018e297 --- /dev/null +++ b/app/app/page.tsx @@ -0,0 +1,12 @@ +import { auth } from '@/auth'; +import { redirect } from 'next/navigation'; + +export default async function Dashboard() { + const session = await auth(); + + if (!session?.user) { + return redirect('/'); + } else { + redirect('/app/overview'); + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..bc15b7b --- /dev/null +++ b/app/globals.css @@ -0,0 +1,115 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 99%; + --foreground: 20 51% 4%; + --muted: 67 60% 90%; + --muted-foreground: 20 7% 25%; + --popover: 20 66% 98%; + --popover-foreground: 20 51% 3%; + --card: 0 0% 100%; + --card-foreground: 20 51% 3%; + --border: 20 15% 94%; + --input: 20 15% 94%; + --primary: 67 94% 54%; + --primary-foreground: 0 0% 12%; + --secondary: 20 12% 92%; + --secondary-foreground: 20 100% 32%; + --accent: 67 94% 54%; + --accent-foreground: 0 0% 5%; + --destructive: 0 80% 50%; + --destructive-foreground: 0 0% 100%; + --ring: 67 94% 54%; + --radius: 0.5rem; + --chart-1: 67 94% 44%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 0 0% 0%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 67 94% 54%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + --color-1: 0 100% 63%; + --color-2: 270 100% 63%; + --color-3: 210 100% 63%; + --color-4: 195 100% 63%; + --color-5: 90 100% 63%; + } + + .dark { + --background: 0 0% 3%; + --foreground: 253 31% 98%; + --muted: 253 7% 13%; + --muted-foreground: 253 0% 63%; + --popover: 0 0% 1%; + --popover-foreground: 253 31% 98%; + --card: 0 0% 1%; + --card-foreground: 253 31% 99%; + --border: 0 0% 15%; + --input: 0 0% 15%; + --primary: 67 94% 54%; + --primary-foreground: 0 0% 1%; + --secondary: 0 0% 9%; + --secondary-foreground: 253 7% 69%; + --accent: 67 94% 54%; + --accent-foreground: 0 0% 0%; + --destructive: 339.2 90.36% 51.18%; + --destructive-foreground: 0 0% 100%; + --ring: 67 94% 54%; + --chart-1: 67 94% 54; + --chart-2: 253 13% 74%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 0 0% 0%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 67 94% 54%; + --sidebar-primary-foreground: 0 0% 0%; + --sidebar-accent: 67 94% 54%; + --sidebar-accent-foreground: 0 0% 0%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 253 91% 58%%; + --color-1: 0 100% 63%; + --color-2: 270 100% 63%; + --color-3: 210 100% 63%; + --color-4: 195 100% 63%; + --color-5: 90 100% 63%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply box-border bg-background text-foreground; + } +} + +@layer utilities { + .min-h-screen { + min-height: 100vh; /* Fallback */ + min-height: 100dvh; + } + .h-screen { + height: 100vh; /* Fallback */ + height: 100dvh; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..bef2093 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,70 @@ +import { auth } from '@/auth'; +import Providers from '@/components/layout/providers'; +import { Toaster } from '@/components/ui/sonner'; +import NextTopLoader from 'nextjs-toploader'; +import './globals.css'; + +import type { Metadata, Viewport } from 'next'; + +const APP_NAME = 'Next-Shadcn-TWA'; +const APP_DEFAULT_TITLE = 'Next-Shadcn-TWA'; +const APP_TITLE_TEMPLATE = '%s - Next-Shadcn-TWA'; +const APP_DESCRIPTION = 'Next-Shadcn-TWA'; + +export const metadata: Metadata = { + applicationName: APP_NAME, + title: { + default: APP_DEFAULT_TITLE, + template: APP_TITLE_TEMPLATE + }, + description: APP_DESCRIPTION, + appleWebApp: { + capable: true, + statusBarStyle: 'default', + title: APP_DEFAULT_TITLE + // startUpImage: [], + }, + formatDetection: { + telephone: false + }, + openGraph: { + type: 'website', + siteName: APP_NAME, + title: { + default: APP_DEFAULT_TITLE, + template: APP_TITLE_TEMPLATE + }, + description: APP_DESCRIPTION + }, + twitter: { + card: 'summary', + title: { + default: APP_DEFAULT_TITLE, + template: APP_TITLE_TEMPLATE + }, + description: APP_DESCRIPTION + } +}; + +export const viewport: Viewport = { + themeColor: '#FFFFFF' +}; + +export default async function RootLayout({ + children +}: { + children: React.ReactNode; +}) { + const session = await auth(); + return ( + + + + + + {children} + + + + ); +} diff --git a/app/manifest.json b/app/manifest.json new file mode 100644 index 0000000..b43c8d8 --- /dev/null +++ b/app/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Next-Shadcn-TWA", + "short_name": "Next-Shadcn-TWA", + "description": "Trusted Web Activity Template built with Next.js, TailwindCSS and Shad-CN", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#DEF81B", + "icons": [ + { + "src": "/icons/192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/700.png", + "sizes": "700x700", + "type": "image/png" + } + ] +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..9738cc6 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { Button } from '@/components/ui/button'; + +export default function NotFound() { + const router = useRouter(); + + return ( +
+ + 404 + +

+ Something's missing +

+

+ Sorry, the page you are looking for doesn't exist or has been + moved. +

+
+ + +
+
+ ); +} diff --git a/app/sw.ts b/app/sw.ts new file mode 100644 index 0000000..a03b9b9 --- /dev/null +++ b/app/sw.ts @@ -0,0 +1,25 @@ +import { defaultCache } from '@serwist/next/worker'; +import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'; +import { Serwist } from 'serwist'; + +// This declares the value of `injectionPoint` to TypeScript. +// `injectionPoint` is the string that will be replaced by the +// actual precache manifest. By default, this string is set to +// `"self.__SW_MANIFEST"`. +declare global { + interface WorkerGlobalScope extends SerwistGlobalConfig { + __SW_MANIFEST: (PrecacheEntry | string)[] | undefined; + } +} + +declare const self: ServiceWorkerGlobalScope; + +const serwist = new Serwist({ + precacheEntries: self.__SW_MANIFEST, + skipWaiting: true, + clientsClaim: true, + navigationPreload: true, + runtimeCaching: defaultCache +}); + +serwist.addEventListeners(); diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000..69718b0 --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,16 @@ +import { NextAuthConfig } from 'next-auth'; +import GoogleProvider from 'next-auth/providers/google'; + +const authConfig = { + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID ?? '', + clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '' + }) + ], + pages: { + signIn: '/' //sigin page + } +} satisfies NextAuthConfig; + +export default authConfig; diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..f428227 --- /dev/null +++ b/auth.ts @@ -0,0 +1,4 @@ +import NextAuth from 'next-auth'; +import authConfig from './auth.config'; + +export const { auth, handlers, signOut, signIn } = NextAuth(authConfig); diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000..e3ab63d Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..5045fe9 --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/components/breadcrumbs.tsx b/components/breadcrumbs.tsx new file mode 100644 index 0000000..3148339 --- /dev/null +++ b/components/breadcrumbs.tsx @@ -0,0 +1,41 @@ +'use client'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from '@/components/ui/breadcrumb'; +import { useBreadcrumbs } from '@/hooks/use-breadcrumbs'; +import { Slash } from 'lucide-react'; +import { Fragment } from 'react'; + +export function Breadcrumbs() { + const items = useBreadcrumbs(); + if (items.length === 0) return null; + + return ( + + + {items.map((item, index) => ( + + {index !== items.length - 1 && ( + + {item.title} + + )} + {index < items.length - 1 && ( + + + + )} + {index === items.length - 1 && ( + {item.title} + )} + + ))} + + + ); +} diff --git a/components/date-range-picker.tsx b/components/date-range-picker.tsx new file mode 100644 index 0000000..823eeb6 --- /dev/null +++ b/components/date-range-picker.tsx @@ -0,0 +1,63 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { CalendarIcon } from '@radix-ui/react-icons'; +import { addDays, format } from 'date-fns'; +import * as React from 'react'; +import { DateRange } from 'react-day-picker'; + +export function CalendarDateRangePicker({ + className +}: React.HTMLAttributes) { + const [date, setDate] = React.useState({ + from: new Date(2023, 0, 20), + to: addDays(new Date(2023, 0, 20), 20) + }); + + return ( +
+ + + + + + + + +
+ ); +} diff --git a/components/file-uploader.tsx b/components/file-uploader.tsx new file mode 100644 index 0000000..1d17af5 --- /dev/null +++ b/components/file-uploader.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { CrossIcon, UploadIcon } from 'lucide-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'; + +interface FileUploaderProps extends React.HTMLAttributes { + /** + * Value of the uploader. + * @type File[] + * @default undefined + * @example value={files} + */ + value?: File[]; + + /** + * Function to be called when the value changes. + * @type React.Dispatch> + * @default undefined + * @example onValueChange={(files) => setFiles(files)} + */ + onValueChange?: React.Dispatch>; + + /** + * Function to be called when files are uploaded. + * @type (files: File[]) => Promise + * @default undefined + * @example onUpload={(files) => uploadFiles(files)} + */ + onUpload?: (files: File[]) => Promise; + + /** + * Progress of the uploaded files. + * @type Record | undefined + * @default undefined + * @example progresses={{ "file1.png": 50 }} + */ + progresses?: Record; + + /** + * 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 ( +
+ 1 || multiple} + disabled={isDisabled} + > + {({ getRootProps, getInputProps, isDragActive }) => ( +
+ + {isDragActive ? ( +
+
+
+

+ Drop the files here +

+
+ ) : ( +
+
+
+
+

+ Drag {`'n'`} drop files here, or click to select files +

+

+ You can upload + {maxFiles > 1 + ? ` ${maxFiles === Infinity ? 'multiple' : maxFiles} + files (up to ${formatBytes(maxSize)} each)` + : ` a file with ${formatBytes(maxSize)}`} +

+
+
+ )} +
+ )} +
+ {files?.length ? ( + +
+ {files?.map((file, index) => ( + onRemove(index)} + progress={progresses?.[file.name]} + /> + ))} +
+
+ ) : null} +
+ ); +} + +interface FileCardProps { + file: File; + onRemove: () => void; + progress?: number; +} + +function FileCard({ file, progress, onRemove }: FileCardProps) { + return ( +
+
+ {isFileWithPreview(file) ? ( + {file.name} + ) : null} +
+
+

+ {file.name} +

+

+ {formatBytes(file.size)} +

+
+ {progress ? : null} +
+
+
+ +
+
+ ); +} + +function isFileWithPreview(file: File): file is File & { preview: string } { + return 'preview' in file && typeof file.preview === 'string'; +} diff --git a/components/form-card-skeleton.tsx b/components/form-card-skeleton.tsx new file mode 100644 index 0000000..4de0ce5 --- /dev/null +++ b/components/form-card-skeleton.tsx @@ -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 ( + + + {/* Title */} + + +
+ {/* Image upload area skeleton */} +
+ {/* Label */} + {/* Upload area */} +
+ + {/* Grid layout for form fields */} +
+ {/* Product Name field */} +
+ {/* Label */} + {/* Input */} +
+ + {/* Category field */} +
+ {/* Label */} + {/* Select */} +
+ + {/* Price field */} +
+ {/* Label */} + {/* Input */} +
+
+ + {/* Description field */} +
+ {/* Label */} + {/* Textarea */} +
+ + {/* Submit button */} + +
+
+
+ ); +} diff --git a/components/icons.tsx b/components/icons.tsx new file mode 100644 index 0000000..c1c3fae --- /dev/null +++ b/components/icons.tsx @@ -0,0 +1,107 @@ +import { + AlertTriangle, + ArrowRight, + Check, + ChevronLeft, + ChevronRight, + CircuitBoardIcon, + Command, + CreditCard, + File, + FileText, + HelpCircle, + Image, + Laptop, + LayoutDashboardIcon, + Loader2, + LogIn, + LucideIcon, + LucideProps, + LucideShoppingBag, + Moon, + MoreVertical, + Pizza, + Plus, + Settings, + SunMedium, + Trash, + Twitter, + User, + UserCircle2Icon, + UserPen, + UserX2Icon, + X +} from 'lucide-react'; + +export type Icon = LucideIcon; + +export const Icons = { + dashboard: LayoutDashboardIcon, + logo: Command, + login: LogIn, + close: X, + product: LucideShoppingBag, + spinner: Loader2, + kanban: CircuitBoardIcon, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + trash: Trash, + employee: UserX2Icon, + post: FileText, + page: File, + userPen: UserPen, + user2: UserCircle2Icon, + media: Image, + settings: Settings, + billing: CreditCard, + ellipsis: MoreVertical, + add: Plus, + warning: AlertTriangle, + user: User, + arrowRight: ArrowRight, + help: HelpCircle, + pizza: Pizza, + sun: SunMedium, + moon: Moon, + laptop: Laptop, + gitHub: ({ ...props }: LucideProps) => ( + + ), + google: ({ ...props }: LucideProps) => ( + + + + + + + + ), + twitter: Twitter, + check: Check +}; diff --git a/components/layout/ThemeToggle/theme-provider.tsx b/components/layout/ThemeToggle/theme-provider.tsx new file mode 100644 index 0000000..04f313a --- /dev/null +++ b/components/layout/ThemeToggle/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { type ThemeProviderProps } from 'next-themes/dist/types'; + +export default function ThemeProvider({ + children, + ...props +}: ThemeProviderProps) { + return {children}; +} diff --git a/components/layout/ThemeToggle/theme-toggle.tsx b/components/layout/ThemeToggle/theme-toggle.tsx new file mode 100644 index 0000000..b232ac5 --- /dev/null +++ b/components/layout/ThemeToggle/theme-toggle.tsx @@ -0,0 +1,37 @@ +'use client'; +import { MoonIcon, SunIcon } from '@radix-ui/react-icons'; +import { useTheme } from 'next-themes'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +type CompProps = {}; +export default function ThemeToggle({}: CompProps) { + const { setTheme } = useTheme(); + return ( + + + + + + setTheme('light')}> + حالت روشن + + setTheme('dark')}> + حالت تیره + + setTheme('system')}> + حالت سیستم + + + + ); +} diff --git a/components/layout/auth-info.tsx b/components/layout/auth-info.tsx new file mode 100644 index 0000000..7c9ecb8 --- /dev/null +++ b/components/layout/auth-info.tsx @@ -0,0 +1,43 @@ +'use client'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +import { + BadgeCheck, + Bell, + ChevronRight, + ChevronsUpDown, + CreditCard, + GalleryVerticalEnd, + LogOut +} from 'lucide-react'; +import { signOut, useSession } from 'next-auth/react'; +import { usePathname } from 'next/navigation'; +import * as React from 'react'; +import { Button } from '../ui/button'; + +export default function AuthInfo() { + const { data: session } = useSession(); + const pathname = usePathname(); + + return ( + + ); +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx new file mode 100644 index 0000000..e6f26c5 --- /dev/null +++ b/components/layout/header.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { SidebarTrigger } from '../ui/sidebar'; +import { Separator } from '../ui/separator'; +import { Breadcrumbs } from '../breadcrumbs'; +import SearchInput from '../search-input'; +import ThemeToggle from './ThemeToggle/theme-toggle'; + +export default function Header() { + return ( +
+
+ + + +
+ +
+
+ +
+ +
+
+ ); +} diff --git a/components/layout/page-container.tsx b/components/layout/page-container.tsx new file mode 100644 index 0000000..ead51da --- /dev/null +++ b/components/layout/page-container.tsx @@ -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 ? ( + +
{children}
+
+ ) : ( +
{children}
+ )} + + ); +} diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx new file mode 100644 index 0000000..2560313 --- /dev/null +++ b/components/layout/providers.tsx @@ -0,0 +1,20 @@ +'use client'; +import React from 'react'; +import ThemeProvider from './ThemeToggle/theme-provider'; +import { SessionProvider, SessionProviderProps } from 'next-auth/react'; + +export default function Providers({ + session, + children +}: { + session: SessionProviderProps['session']; + children: React.ReactNode; +}) { + return ( + <> + + {children} + + + ); +} diff --git a/components/modals/alert-modal.tsx b/components/modals/alert-modal.tsx new file mode 100644 index 0000000..bc33ae9 --- /dev/null +++ b/components/modals/alert-modal.tsx @@ -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 = ({ + isOpen, + onClose, + onConfirm, + loading +}) => { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return null; + } + + return ( + +
+ + +
+
+ ); +}; diff --git a/components/mode-toggle.tsx b/components/mode-toggle.tsx new file mode 100644 index 0000000..77a6f14 --- /dev/null +++ b/components/mode-toggle.tsx @@ -0,0 +1,37 @@ +'use client'; + +import * as React from 'react'; +import { useTheme } from 'next-themes'; +import { MoonIcon, SunIcon } from '@radix-ui/react-icons'; + +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider +} from '@/components/ui/tooltip'; + +export function ModeToggle() { + const { setTheme, theme } = useTheme(); + + return ( + + + + + + Switch Theme + + + ); +} diff --git a/components/nav-main.tsx b/components/nav-main.tsx new file mode 100644 index 0000000..6510855 --- /dev/null +++ b/components/nav-main.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { ChevronRight, type LucideIcon } from 'lucide-react'; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from '@/components/ui/collapsible'; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem +} from '@/components/ui/sidebar'; + +export function NavMain({ + items +}: { + items: { + title: string; + url: string; + icon?: LucideIcon; + isActive?: boolean; + items?: { + title: string; + url: string; + }[]; + }[]; +}) { + return ( + + Platform + + {items.map((item) => ( + + + + + {item.icon && } + {item.title} + + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + + ))} + + + ); +} diff --git a/components/nav-projects.tsx b/components/nav-projects.tsx new file mode 100644 index 0000000..ff3e600 --- /dev/null +++ b/components/nav-projects.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { + Folder, + Forward, + MoreHorizontal, + Trash2, + type LucideIcon +} from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + useSidebar +} from '@/components/ui/sidebar'; + +export function NavProjects({ + projects +}: { + projects: { + name: string; + url: string; + icon: LucideIcon; + }[]; +}) { + const { isMobile } = useSidebar(); + + return ( + + Projects + + {projects.map((item) => ( + + + + + {item.name} + + + + + + + More + + + + + + View Project + + + + Share Project + + + + + Delete Project + + + + + ))} + + + + More + + + + + ); +} diff --git a/components/providers/theme-provider.tsx b/components/providers/theme-provider.tsx new file mode 100644 index 0000000..530befd --- /dev/null +++ b/components/providers/theme-provider.tsx @@ -0,0 +1,9 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { type ThemeProviderProps } from 'next-themes/dist/types'; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/components/search-input.tsx b/components/search-input.tsx new file mode 100644 index 0000000..011b519 --- /dev/null +++ b/components/search-input.tsx @@ -0,0 +1,24 @@ +'use client'; +import { Input } from '@/components/ui/input'; +import { useKBar } from 'kbar'; +import { ArrowRight, Search } from 'lucide-react'; +import { Button } from './ui/button'; + +export default function SearchInput() { + const { query } = useKBar(); + return ( +
+ +
+ ); +} diff --git a/components/team-switcher.tsx b/components/team-switcher.tsx new file mode 100644 index 0000000..bd10958 --- /dev/null +++ b/components/team-switcher.tsx @@ -0,0 +1,89 @@ +'use client'; + +import * as React from 'react'; +import { ChevronsUpDown, Plus } from 'lucide-react'; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar +} from '@/components/ui/sidebar'; + +export function TeamSwitcher({ + teams +}: { + teams: { + name: string; + logo: React.ElementType; + plan: string; + }[]; +}) { + const { isMobile } = useSidebar(); + const [activeTeam, setActiveTeam] = React.useState(teams[0]); + + return ( + + + + + +
+ +
+
+ + {activeTeam.name} + + {activeTeam.plan} +
+ +
+
+ + + Teams + + {teams.map((team, index) => ( + setActiveTeam(team)} + className="gap-2 p-2" + > +
+ +
+ {team.name} + ⌘{index + 1} +
+ ))} + + +
+ +
+
Add team
+
+
+
+
+
+ ); +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..0e8b3b1 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +'use client'; + +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; + +import { cn } from '@/lib/utils'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..ca10623 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + // yes, you have to set a timeout + setTimeout(() => (document.body.style.pointerEvents = ''), 100) + } + {...props} + /> +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel +}; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..6f5b983 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive' + } + }, + defaultVariants: { + variant: 'default' + } + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..1346957 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/components/ui/background-beams.tsx b/components/ui/background-beams.tsx new file mode 100644 index 0000000..f0809b5 --- /dev/null +++ b/components/ui/background-beams.tsx @@ -0,0 +1,141 @@ +'use client'; +import React from 'react'; +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; + +export const BackgroundBeams = React.memo( + ({ className }: { className?: string }) => { + const paths = [ + 'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875', + 'M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867', + 'M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859', + 'M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851', + 'M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843', + 'M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835', + 'M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827', + 'M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819', + 'M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811', + 'M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803', + 'M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795', + 'M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787', + 'M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779', + 'M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771', + 'M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763', + 'M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755', + 'M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747', + 'M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739', + 'M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731', + 'M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723', + 'M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715', + 'M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707', + 'M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699', + 'M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691', + 'M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683', + 'M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675', + 'M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667', + 'M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659', + 'M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651', + 'M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643', + 'M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635', + 'M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627', + 'M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619', + 'M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611', + 'M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603', + 'M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595', + 'M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587', + 'M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579', + 'M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571', + 'M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563', + 'M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555', + 'M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547', + 'M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539', + 'M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531', + 'M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523', + 'M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515', + 'M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507', + 'M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499', + 'M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491', + 'M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483' + ]; + return ( +
+ + + + {paths.map((path, index) => ( + + ))} + + {paths.map((path, index) => ( + + + + + + + ))} + + + + + + + + +
+ ); + } +); + +BackgroundBeams.displayName = 'BackgroundBeams'; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..4ce58c0 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground' + } + }, + defaultVariants: { + variant: 'default' + } + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..f209322 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'; +import { Slot } from '@radix-ui/react-slot'; + +import { cn } from '@/lib/utils'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>