321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { store } from '@/actions/App/Http/Controllers/Settings/LabelController';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/ui/command';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover';
|
|
import { cn } from '@/lib/utils';
|
|
import { getLabelColorClasses, LABEL_COLORS, type Label } from '@/types/label';
|
|
import { Check, ChevronsUpDown, Plus, Tag, X } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
|
|
interface LabelComboboxProps {
|
|
value: string[];
|
|
onValueChange: (value: string[]) => void;
|
|
labels: Label[];
|
|
disabled?: boolean;
|
|
placeholder?: string;
|
|
triggerClassName?: string;
|
|
allowCreate?: boolean;
|
|
allowRemoveAll?: boolean;
|
|
onLabelCreated?: (label: Label) => void;
|
|
}
|
|
|
|
export function LabelCombobox({
|
|
value,
|
|
onValueChange,
|
|
labels = [],
|
|
disabled = false,
|
|
placeholder = 'Add labels...',
|
|
triggerClassName,
|
|
allowCreate = true,
|
|
allowRemoveAll = false,
|
|
onLabelCreated,
|
|
}: LabelComboboxProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
|
|
const selectedLabels = labels.filter((l) => value.includes(l.id));
|
|
|
|
const sortedLabels = [...labels].sort((a, b) =>
|
|
a.name.localeCompare(b.name),
|
|
);
|
|
|
|
const handleSelect = (labelId: string) => {
|
|
if (value.includes(labelId)) {
|
|
onValueChange(value.filter((id) => id !== labelId));
|
|
} else {
|
|
onValueChange([...value, labelId]);
|
|
}
|
|
};
|
|
|
|
const handleRemove = (labelId: string, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onValueChange(value.filter((id) => id !== labelId));
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
if (!inputValue.trim() || isCreating) return;
|
|
|
|
const existingLabel = labels.find(
|
|
(l) => l.name.toLowerCase() === inputValue.toLowerCase(),
|
|
);
|
|
if (existingLabel) {
|
|
handleSelect(existingLabel.id);
|
|
setInputValue('');
|
|
return;
|
|
}
|
|
|
|
setIsCreating(true);
|
|
try {
|
|
const randomColor =
|
|
LABEL_COLORS[Math.floor(Math.random() * LABEL_COLORS.length)];
|
|
|
|
const response = await fetch(store.url(), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-XSRF-TOKEN': decodeURIComponent(
|
|
document.cookie
|
|
.split('; ')
|
|
.find((row) => row.startsWith('XSRF-TOKEN='))
|
|
?.split('=')[1] || '',
|
|
),
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
name: inputValue.trim(),
|
|
color: randomColor,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to create label');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const newLabel = data.data || data;
|
|
|
|
if (newLabel) {
|
|
onValueChange([...value, newLabel.id]);
|
|
setInputValue('');
|
|
onLabelCreated?.(newLabel);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to create label:', error);
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
};
|
|
|
|
const showCreateOption =
|
|
allowCreate &&
|
|
inputValue.trim() &&
|
|
!labels.some((l) => l.name.toLowerCase() === inputValue.toLowerCase());
|
|
|
|
return (
|
|
<Popover modal open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className={cn(
|
|
'min-h-10 w-full justify-between',
|
|
triggerClassName,
|
|
)}
|
|
disabled={disabled}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{selectedLabels.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{selectedLabels.map((label) => {
|
|
const colorClasses = getLabelColorClasses(
|
|
label.color,
|
|
);
|
|
return (
|
|
<Badge
|
|
key={label.id}
|
|
className={cn(
|
|
'gap-1 px-2 py-0.5',
|
|
colorClasses.bg,
|
|
colorClasses.text,
|
|
)}
|
|
>
|
|
<Tag className="h-3 w-3" />
|
|
{label.name}
|
|
<span
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={(e) =>
|
|
handleRemove(label.id, e)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
e.key === 'Enter' ||
|
|
e.key === ' '
|
|
) {
|
|
handleRemove(
|
|
label.id,
|
|
e as unknown as React.MouseEvent,
|
|
);
|
|
}
|
|
}}
|
|
className="ml-0.5 cursor-pointer rounded-full hover:bg-black/10"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</span>
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground">
|
|
{placeholder}
|
|
</span>
|
|
)}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-[300px] p-0" align="start">
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder="Search or create labels..."
|
|
value={inputValue}
|
|
onValueChange={setInputValue}
|
|
/>
|
|
<CommandList>
|
|
{sortedLabels.length === 0 && !showCreateOption && (
|
|
<CommandEmpty>No labels found.</CommandEmpty>
|
|
)}
|
|
{allowRemoveAll && (
|
|
<CommandItem
|
|
onSelect={() => {
|
|
onValueChange([]);
|
|
setOpen(false);
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
Remove all labels
|
|
</CommandItem>
|
|
)}
|
|
{showCreateOption && (
|
|
<CommandItem
|
|
onSelect={handleCreate}
|
|
disabled={isCreating}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
{isCreating
|
|
? 'Creating...'
|
|
: `Create "${inputValue.trim()}"`}
|
|
</CommandItem>
|
|
)}
|
|
{sortedLabels
|
|
.filter(
|
|
(label) =>
|
|
!inputValue ||
|
|
label.name
|
|
.toLowerCase()
|
|
.includes(inputValue.toLowerCase()),
|
|
)
|
|
.map((label) => {
|
|
const colorClasses = getLabelColorClasses(
|
|
label.color,
|
|
);
|
|
const isSelected = value.includes(label.id);
|
|
return (
|
|
<CommandItem
|
|
key={label.id}
|
|
value={label.name}
|
|
onSelect={() => handleSelect(label.id)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
'flex h-5 w-5 items-center justify-center rounded-full',
|
|
colorClasses.bg,
|
|
)}
|
|
>
|
|
<Tag
|
|
className={cn(
|
|
'h-3 w-3',
|
|
colorClasses.text,
|
|
)}
|
|
/>
|
|
</div>
|
|
<span className="truncate">
|
|
{label.name}
|
|
</span>
|
|
</div>
|
|
<Check
|
|
className={cn(
|
|
'ml-auto h-4 w-4',
|
|
isSelected
|
|
? 'opacity-100'
|
|
: 'opacity-0',
|
|
)}
|
|
/>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
export function LabelBadge({ label }: { label: Label }) {
|
|
const colorClasses = getLabelColorClasses(label.color);
|
|
return (
|
|
<Badge
|
|
className={cn(
|
|
'gap-1 px-2 py-0.5',
|
|
colorClasses.bg,
|
|
colorClasses.text,
|
|
)}
|
|
>
|
|
<Tag className="h-3 w-3" />
|
|
{label.name}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export function LabelBadges({
|
|
labels,
|
|
max = 3,
|
|
}: {
|
|
labels: Label[];
|
|
max?: number;
|
|
}) {
|
|
if (!labels || labels.length === 0) return null;
|
|
|
|
const displayLabels = labels.slice(0, max);
|
|
const remainingCount = labels.length - max;
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{displayLabels.map((label) => (
|
|
<LabelBadge key={label.id} label={label} />
|
|
))}
|
|
{remainingCount > 0 && (
|
|
<Badge variant="outline" className="px-2 py-0.5">
|
|
+{remainingCount}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|