#591 - sites [wip]

This commit is contained in:
Saeed Vaziry
2025-05-25 22:17:19 +02:00
parent ff11fb44e0
commit f5fdbae4ac
77 changed files with 2156 additions and 414 deletions

View File

@ -0,0 +1,82 @@
import { InputHTMLAttributes } from 'react';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DynamicFieldConfig } from '@/types/dynamic-field-config';
import InputError from '@/components/ui/input-error';
import { FormField } from '@/components/ui/form';
interface DynamicFieldProps {
value: string | number | boolean | string[] | undefined;
onChange: (value: string | number | boolean | string[]) => void;
config: DynamicFieldConfig;
error?: string;
}
export default function DynamicField({ value, onChange, config, error }: DynamicFieldProps) {
const defaultLabel = config.name.replaceAll('_', ' ');
const label = config?.label || defaultLabel;
if (!value) {
value = config?.default || '';
}
// Handle checkbox
if (config?.type === 'checkbox') {
return (
<FormField>
<div className="flex items-center space-x-2">
<Switch id={`switch-${config.name}`} checked={value as boolean} onCheckedChange={onChange} />
<Label htmlFor={`switch-${config.name}`}>{label}</Label>
{config.description && <p className="text-muted-foreground text-xs">{config.description}</p>}
<InputError message={error} />
</div>
</FormField>
);
}
// Handle select
if (config?.type === 'select' && config.options) {
return (
<FormField>
<Label htmlFor={config.name} className="capitalize">
{label}
</Label>
<Select value={value as string} onValueChange={onChange}>
<SelectTrigger id={config.name}>
<SelectValue placeholder={config.placeholder || `Select ${label}`} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options.map((item) => (
<SelectItem key={`${config.name}-${item}`} value={item}>
{item}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{config.description && <p className="text-muted-foreground text-xs">{config.description}</p>}
<InputError message={error} />
</FormField>
);
}
// Default to text input
const props: InputHTMLAttributes<HTMLInputElement> = {};
if (config?.placeholder) {
props.placeholder = config.placeholder;
}
return (
<FormField>
<Label htmlFor={config.name} className="capitalize">
{label}
</Label>
<Input type="text" name={config.name} id={config.name} value={(value as string) || ''} onChange={(e) => onChange(e.target.value)} {...props} />
{config.description && <p className="text-muted-foreground text-xs">{config.description}</p>}
<InputError message={error} />
</FormField>
);
}

View File

@ -19,7 +19,7 @@ function PopoverContent({ className, align = 'center', sideOffset = 4, ...props
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 w-[var(--radix-popover-trigger-width)] min-w-[8rem] overflow-hidden rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@ -0,0 +1,123 @@
'use client';
import * as React from 'react';
import { X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface TagsInputProps {
value?: string[];
onValueChange?: (tags: string[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
maxTags?: number;
allowDuplicates?: boolean;
separator?: string | RegExp;
}
export function TagsInput({
value = [],
onValueChange,
placeholder = 'Add tags...',
className,
disabled = false,
maxTags,
allowDuplicates = false,
separator = ',',
...props
}: TagsInputProps & React.InputHTMLAttributes<HTMLInputElement>) {
const [inputValue, setInputValue] = React.useState('');
const [tags, setTags] = React.useState<string[]>(value);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
setTags(value);
}, [value]);
const addTag = (tag: string) => {
const trimmedTag = tag.trim();
if (!trimmedTag) return;
if (!allowDuplicates && tags.includes(trimmedTag)) return;
if (maxTags && tags.length >= maxTags) return;
const newTags = [...tags, trimmedTag];
setTags(newTags);
onValueChange?.(newTags);
setInputValue('');
};
const removeTag = (indexToRemove: number) => {
const newTags = tags.filter((_, index) => index !== indexToRemove);
setTags(newTags);
onValueChange?.(newTags);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
addTag(inputValue);
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
removeTag(tags.length - 1);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
if (typeof separator === 'string' && newValue.includes(separator)) {
const newTags = newValue.split(separator);
const lastTag = newTags.pop() || '';
newTags.forEach((tag) => addTag(tag));
setInputValue(lastTag);
} else if (separator instanceof RegExp && separator.test(newValue)) {
const newTags = newValue.split(separator);
const lastTag = newTags.pop() || '';
newTags.forEach((tag) => addTag(tag));
setInputValue(lastTag);
} else {
setInputValue(newValue);
}
};
const handleContainerClick = () => {
inputRef.current?.focus();
};
return (
<div className={cn('gap-2 space-y-2', disabled && 'cursor-not-allowed opacity-50', className)} onClick={handleContainerClick}>
<Input
ref={inputRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={tags.length === 0 ? placeholder : ''}
disabled={disabled || (maxTags ? tags.length >= maxTags : false)}
{...props}
/>
{tags.map((tag, index) => (
<Badge key={index} variant="outline" className="mr-1 gap-2">
{tag}
{!disabled && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-auto p-0!"
onClick={(e) => {
e.stopPropagation();
removeTag(index);
}}
>
<X className="h-3 w-3" />
</Button>
)}
</Badge>
))}
</div>
);
}