set-up
This commit is contained in:
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