This commit is contained in:
phaichayon
2026-04-16 16:59:46 +07:00
parent 4702150af1
commit ba1ffed211
165 changed files with 18353 additions and 219 deletions

View 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;
}

View 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>
);
}

View 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';
}

View 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>
);
}

View 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>
);
}

View 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);

View 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);

View 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';

View 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);

View 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);

View 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);

View 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);

View 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);

View 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);

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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
View 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
};

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}
}

View 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>
);
};

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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
);

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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'
}
];

View 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>
);
}