mirror of
https://github.com/vitodeploy/vito.git
synced 2025-07-02 22:46:16 +00:00
#591 - sites [wip]
This commit is contained in:
@ -3,8 +3,13 @@ import { ProjectSwitch } from '@/components/project-switch';
|
||||
import { SlashIcon } from 'lucide-react';
|
||||
import { ServerSwitch } from '@/components/server-switch';
|
||||
import AppCommand from '@/components/app-command';
|
||||
import { SiteSwitch } from '@/components/site-switch';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { SharedData } from '@/types';
|
||||
|
||||
export function AppHeader() {
|
||||
const page = usePage<SharedData>();
|
||||
|
||||
return (
|
||||
<header className="bg-background -ml-1 flex h-12 shrink-0 items-center justify-between gap-2 border-b p-4 md:-ml-2">
|
||||
<div className="flex items-center">
|
||||
@ -13,6 +18,12 @@ export function AppHeader() {
|
||||
<ProjectSwitch />
|
||||
<SlashIcon className="size-3" />
|
||||
<ServerSwitch />
|
||||
{page.props.server && (
|
||||
<>
|
||||
<SlashIcon className="size-3" />
|
||||
<SiteSwitch />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AppCommand />
|
||||
|
@ -14,7 +14,7 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { type NavItem } from '@/types';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { BookOpen, ChevronRightIcon, CogIcon, Folder, ServerIcon } from 'lucide-react';
|
||||
import { BookOpen, ChevronRightIcon, CogIcon, Folder, MousePointerClickIcon, ServerIcon } from 'lucide-react';
|
||||
import AppLogo from './app-logo';
|
||||
import { Icon } from '@/components/icon';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
@ -25,6 +25,11 @@ const mainNavItems: NavItem[] = [
|
||||
href: route('servers'),
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
title: 'Sites',
|
||||
href: route('sites.all'),
|
||||
icon: MousePointerClickIcon,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: route('settings'),
|
||||
@ -143,6 +148,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?
|
||||
}
|
||||
>
|
||||
<Link href={childItem.href} prefetch>
|
||||
{childItem.icon && <childItem.icon />}
|
||||
<span>{childItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
@ -1,26 +1,68 @@
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { router } from '@inertiajs/react';
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PaginatedData } from '@/types';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
paginatedData?: PaginatedData<TData>;
|
||||
data?: TData[];
|
||||
className?: string;
|
||||
modal?: boolean;
|
||||
onPageChange?: (page: number) => void;
|
||||
isFetching?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data, className, modal }: DataTableProps<TData, TValue>) {
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
paginatedData,
|
||||
data,
|
||||
className,
|
||||
modal,
|
||||
onPageChange,
|
||||
isFetching,
|
||||
isLoading,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
// Use paginatedData.data if available, otherwise fall back to data prop
|
||||
const tableData = paginatedData?.data || data || [];
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const extraClasses = modal && 'border-none shadow-none';
|
||||
|
||||
const handlePageChange = (url: string) => {
|
||||
if (onPageChange) {
|
||||
// Use custom page change handler (for axios/API calls)
|
||||
const urlObj = new URL(url);
|
||||
const page = urlObj.searchParams.get('page');
|
||||
if (page) {
|
||||
onPageChange(parseInt(page));
|
||||
return;
|
||||
}
|
||||
|
||||
onPageChange(1);
|
||||
} else {
|
||||
// Use Inertia router for server-side rendered pages
|
||||
router.get(url, {}, { preserveState: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-md border shadow-xs', className, extraClasses)}>
|
||||
<div className={cn('relative overflow-hidden rounded-md border shadow-xs', className, extraClasses)}>
|
||||
{isLoading && (
|
||||
<div className="absolute top-0 right-0 left-0 h-[2px] overflow-hidden">
|
||||
<div className="animate-loading-bar bg-primary absolute inset-0 w-full" />
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@ -53,6 +95,62 @@ export function DataTable<TData, TValue>({ columns, data, className, modal }: Da
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{paginatedData && (
|
||||
<div className="flex items-center justify-between border-t px-4 py-3">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
{paginatedData.meta.from && paginatedData.meta.to && (
|
||||
<span>
|
||||
Showing {paginatedData.meta.from} to {paginatedData.meta.to}
|
||||
{paginatedData.meta.total && ` of ${paginatedData.meta.total}`} results
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.first && handlePageChange(paginatedData.links.first)}
|
||||
disabled={!paginatedData.links.first || isFetching}
|
||||
>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.prev && handlePageChange(paginatedData.links.prev)}
|
||||
disabled={!paginatedData.links.prev || isFetching}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center text-sm font-medium">
|
||||
Page {paginatedData.meta.current_page}
|
||||
{paginatedData.meta.last_page && ` of ${paginatedData.meta.last_page}`}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.next && handlePageChange(paginatedData.links.next)}
|
||||
disabled={!paginatedData.links.next || isFetching}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => paginatedData.links.last && handlePageChange(paginatedData.links.last)}
|
||||
disabled={!paginatedData.links.last || isFetching}
|
||||
>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
86
resources/js/components/site-switch.tsx
Normal file
86
resources/js/components/site-switch.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useForm, usePage } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react';
|
||||
import { useInitials } from '@/hooks/use-initials';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { type Site } from '@/types/site';
|
||||
import type { SharedData } from '@/types';
|
||||
import CreateSite from '@/pages/sites/components/create-site';
|
||||
|
||||
export function SiteSwitch() {
|
||||
const page = usePage<SharedData>();
|
||||
const [selectedSite, setSelectedSite] = useState(page.props.site || null);
|
||||
const initials = useInitials();
|
||||
const form = useForm();
|
||||
|
||||
const handleSiteChange = (site: Site) => {
|
||||
setSelectedSite(site);
|
||||
form.post(route('sites.switch', { server: site.server_id, site: site.id }));
|
||||
};
|
||||
|
||||
return (
|
||||
page.props.server &&
|
||||
page.props.serverSites && (
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="px-1!">
|
||||
{selectedSite && (
|
||||
<>
|
||||
<Avatar className="size-6 rounded-sm">
|
||||
<AvatarFallback className="rounded-sm">{initials(selectedSite?.domain ?? '')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:flex">{selectedSite?.domain}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!selectedSite && (
|
||||
<>
|
||||
<Avatar className="size-6 rounded-sm">
|
||||
<AvatarFallback className="rounded-sm">S</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden lg:flex">Select a site</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ChevronsUpDownIcon size={5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
{page.props.serverSites.length > 0 ? (
|
||||
page.props.serverSites.map((site) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={`site-${site.id.toString()}`}
|
||||
checked={selectedSite?.id === site.id}
|
||||
onCheckedChange={() => handleSiteChange(site)}
|
||||
>
|
||||
{site.domain}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>No sites</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<CreateSite server={page.props.server}>
|
||||
<DropdownMenuItem className="gap-0" onSelect={(e) => e.preventDefault()}>
|
||||
<div className="flex items-center">
|
||||
<PlusIcon size={5} />
|
||||
<span className="ml-2">Create new site</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</CreateSite>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
33
resources/js/components/status-ripple.tsx
Normal file
33
resources/js/components/status-ripple.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const variants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary/90',
|
||||
success: 'bg-success/90',
|
||||
info: 'bg-info/90',
|
||||
warning: 'bg-warning/90',
|
||||
danger: 'bg-destructive/90',
|
||||
destructive: 'bg-destructive/90',
|
||||
gray: 'bg-gray/90',
|
||||
outline: 'bg-transparent border border-foreground/20 hover:bg-foreground/10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
function StatusRipple({ className, variant, ...props }: React.ComponentProps<'span'> & VariantProps<typeof variants>) {
|
||||
return (
|
||||
<span className={cn('relative flex size-3', className)} {...props}>
|
||||
<span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-75', variants({ variant }))}></span>
|
||||
<span className={cn('relative inline-flex size-3 rounded-full', variants({ variant }))}></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { StatusRipple, variants };
|
@ -2,7 +2,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function TableSkeleton({ modal }: { modal?: boolean }) {
|
||||
export function TableSkeleton({ cells, rows, modal }: { cells: number; rows: number; modal?: boolean }) {
|
||||
const extraClasses = modal && 'border-none shadow-none';
|
||||
|
||||
return (
|
||||
@ -10,35 +10,21 @@ export function TableSkeleton({ modal }: { modal?: boolean }) {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Skeleton className="h-3 w-[100px]" />
|
||||
</TableHead>
|
||||
{[...Array(cells)].map((_, i) => (
|
||||
<TableHead key={i}>
|
||||
<Skeleton className="h-3" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[100px]" />
|
||||
</TableCell>
|
||||
{[...Array(rows)].map((_, i) => (
|
||||
<TableRow key={i} className="h-[60px]!">
|
||||
{[...Array(cells)].map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
82
resources/js/components/ui/dynamic-field.tsx
Normal file
82
resources/js/components/ui/dynamic-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
|
26
resources/js/components/ui/switch.tsx
Normal file
26
resources/js/components/ui/switch.tsx
Normal 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 };
|
123
resources/js/components/ui/tags-input.tsx
Normal file
123
resources/js/components/ui/tags-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
import { User } from '@/types/user';
|
||||
import { useState } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function UserSelect({ onChange }: { onChange: (selectedUser: User) => void }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState<string>();
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (open) {
|
||||
fetchUsers();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const response = await axios.get(route('users.json', { query: query }));
|
||||
|
||||
if (response.status === 200) {
|
||||
setUsers(response.data as User[]);
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between">
|
||||
{value ? users.find((user) => user.id === parseInt(value))?.name : 'Select user...'}
|
||||
<ChevronsUpDownIcon className="opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Search user..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{query === '' ? 'Start typing to load results' : 'No results found.'}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={`user-select-${user.id}`}
|
||||
value={user.id.toString()}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? '' : currentValue);
|
||||
setOpen(false);
|
||||
onChange(users.find((u) => u.id.toString() === currentValue) as User);
|
||||
}}
|
||||
className="truncate"
|
||||
>
|
||||
{user.name} ({user.email})
|
||||
<CheckIcon className={cn('ml-auto', value && parseInt(value) === user.id ? 'opacity-100' : 'opacity-0')} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user