Files
buzz-sheet/components/admin/ui/catalog-select.tsx
T
2026-10-02 22:12:43 +07:00

341 lines
11 KiB
TypeScript

"use client";
import Image from "next/image";
import { CircleHelpIcon } from "lucide-react";
import { TeamArtifactIcon } from "@/components/public/team-search";
import {
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
FuzzyCombobox,
} from "@/components/ui/combobox";
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
import { cn } from "@/lib/utils";
export interface AdminCatalogItem {
key: string;
name: string;
aliases?: string[];
imageUrl: string;
rarity?: number | null;
}
export interface AdminCatalogCharacter extends AdminCatalogItem {
aliases: string[];
rarity: number | null;
element: string | null;
elementIconUrl?: string | null;
weaponType: string | null;
splashImageUrl: string | null;
}
export interface AdminCatalogWeapon extends AdminCatalogItem {
aliases: string[];
rarity: number | null;
weaponType: string | null;
}
export interface AdminCatalogArtifact extends AdminCatalogItem {
aliases: string[];
rarity: number | null;
}
type CommonSelectProps<Item extends AdminCatalogItem> = {
items: Item[];
placeholder: string;
emptyLabel?: string;
invalid?: boolean;
rarityBackground?: boolean;
characterBackground?: boolean;
searchNames?: (item: Item) => string[];
};
type CatalogDropdownProps<Item extends AdminCatalogItem> = CommonSelectProps<Item> & {
value: string;
onValueChange: (value: string) => void;
showSelectedImage?: boolean;
showClear?: boolean;
label?: string;
};
export function CatalogDropdown<Item extends AdminCatalogItem>({
items,
value,
onValueChange,
placeholder,
emptyLabel = "ไม่พบรายการ",
invalid = false,
rarityBackground = false,
characterBackground = false,
showSelectedImage = false,
showClear = true,
label,
searchNames = (item) => [item.name, ...(item.aliases ?? [])],
}: CatalogDropdownProps<Item>) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<div className="flex min-w-0 items-center gap-2">
{showSelectedImage && selected ? (
<div
className={cn(
"relative size-10 shrink-0 overflow-hidden rounded-md",
rarityBackground && (characterBackground ? characterRarityGradientClass(selected.key, selected.rarity) : rarityGradientClass(selected.rarity)),
)}
>
<Image src={selected.imageUrl} alt="" fill sizes="40px" className="object-contain" />
</div>
) : null}
<div className="min-w-0 flex-1">
<FuzzyCombobox
items={items}
value={selected}
onValueChange={(next) => onValueChange(next?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
searchNames={searchNames}
autoHighlight
>
<ComboboxInput
className="w-full"
placeholder={placeholder}
aria-label={label ?? placeholder}
showClear={showClear}
aria-invalid={invalid}
/>
<ComboboxContent className="min-w-72">
<ComboboxEmpty>{emptyLabel}</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.key} value={item}>
<CatalogOptionImage item={item} rarityBackground={rarityBackground} characterBackground={characterBackground} />
<span className="whitespace-nowrap">{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</FuzzyCombobox>
</div>
</div>
);
}
type CatalogSlotSelectProps<Item extends AdminCatalogItem> = CommonSelectProps<Item> & {
value: string;
onValueChange: (value: string) => void;
label: string;
className?: string;
};
function CatalogSlotSelect<Item extends AdminCatalogItem>({
items,
value,
onValueChange,
label,
placeholder,
emptyLabel = "ไม่พบรายการ",
invalid = false,
rarityBackground = true,
characterBackground = false,
searchNames = (item) => [item.name, ...(item.aliases ?? [])],
className,
}: CatalogSlotSelectProps<Item>) {
const selected = items.find((item) => item.key === value) ?? null;
return (
<FuzzyCombobox
items={items}
value={selected}
onValueChange={(next) => onValueChange(next?.key ?? "")}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
searchNames={searchNames}
autoHighlight
>
<ComboboxTrigger
aria-label={selected ? `${label}: ${selected.name}` : label}
aria-invalid={invalid}
className={cn(
"relative flex aspect-square w-full items-center justify-center overflow-hidden rounded-md border bg-muted p-0 outline-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&>svg]:hidden",
rarityBackground && selected && (characterBackground ? characterRarityGradientClass(selected.key, selected.rarity) : rarityGradientClass(selected.rarity)),
className,
)}
>
{selected ? (
<Image src={selected.imageUrl} alt="" fill sizes="96px" className="object-contain p-0.5" />
) : (
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
)}
</ComboboxTrigger>
<ComboboxContent className="min-w-72">
<ComboboxInput placeholder={placeholder} showClear showTrigger={false} />
<ComboboxEmpty>{emptyLabel}</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.key} value={item}>
<CatalogOptionImage item={item} rarityBackground={rarityBackground} characterBackground={characterBackground} />
<span className="whitespace-nowrap">{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</FuzzyCombobox>
);
}
type DomainSelectProps<Item extends AdminCatalogItem> = CommonSelectProps<Item> & {
value: string;
onValueChange: (value: string) => void;
presentation?: "dropdown" | "slot";
label?: string;
className?: string;
showSelectedImage?: boolean;
showClear?: boolean;
};
function DomainCatalogSelect<Item extends AdminCatalogItem>({
presentation = "dropdown",
label,
className,
showSelectedImage,
rarityBackground = true,
...props
}: DomainSelectProps<Item>) {
if (presentation === "slot") {
return (
<CatalogSlotSelect
{...props}
rarityBackground={rarityBackground}
label={label ?? props.placeholder}
className={className}
/>
);
}
return <CatalogDropdown {...props} label={label} rarityBackground={rarityBackground} showSelectedImage={showSelectedImage} />;
}
export function CharacterSelect<Item extends AdminCatalogItem>(props: DomainSelectProps<Item>) {
return <DomainCatalogSelect {...props} characterBackground />;
}
export function WeaponSelect<Item extends AdminCatalogItem>(props: DomainSelectProps<Item>) {
return <DomainCatalogSelect {...props} />;
}
type SingleArtifactSelectProps<Item extends AdminCatalogItem> = DomainSelectProps<Item> & {
multiple?: false;
};
type MultipleArtifactSelectProps<Item extends AdminCatalogItem> = Omit<
CommonSelectProps<Item>,
"rarityBackground"
> & {
multiple: true;
values: string[];
onValuesChange: (values: string[]) => void;
label: string;
max?: number;
className?: string;
};
export function ArtifactSelect<Item extends AdminCatalogItem>(
props: SingleArtifactSelectProps<Item> | MultipleArtifactSelectProps<Item>,
) {
if (!props.multiple) return <DomainCatalogSelect {...props} />;
const {
items,
values,
onValuesChange,
label,
placeholder,
emptyLabel = "ไม่พบชุด Artifact",
invalid = false,
max = 2,
searchNames = (item) => [item.name, ...(item.aliases ?? [])],
className,
} = props;
const selected = values.flatMap((value) => {
const item = items.find((candidate) => candidate.key === value);
return item ? [item] : [];
});
const iconValue = (item: Item | undefined) => item
? { key: item.key, name: item.name, imageUrl: item.imageUrl, rarity: item.rarity ?? null }
: null;
return (
<FuzzyCombobox
multiple
items={items}
value={selected}
onValueChange={(next) => onValuesChange(next.slice(0, max).map((item) => item.key))}
itemToStringLabel={(item) => item.name}
itemToStringValue={(item) => item.key}
isItemEqualToValue={(item, value) => item.key === value.key}
searchNames={searchNames}
autoHighlight
>
<ComboboxTrigger
aria-label={selected.length ? `${label}: ${selected.map((item) => item.name).join(", ")}` : label}
aria-invalid={invalid}
className={cn(
"relative aspect-square w-full overflow-hidden rounded-md border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 [&>svg]:hidden",
className,
)}
>
{selected.length ? (
<TeamArtifactIcon
first={iconValue(selected[0])}
second={iconValue(selected[1])}
className="border-0"
sizes="96px"
/>
) : (
<CircleHelpIcon className="size-8 text-muted-foreground" aria-hidden="true" />
)}
</ComboboxTrigger>
<ComboboxContent className="min-w-72">
<ComboboxInput placeholder={placeholder} showClear showTrigger={false} />
<ComboboxEmpty>{emptyLabel}</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem
key={item.key}
value={item}
disabled={selected.length >= max && !selected.some((candidate) => candidate.key === item.key)}
>
<CatalogOptionImage item={item} rarityBackground />
<span className="whitespace-nowrap">{item.name}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</FuzzyCombobox>
);
}
function CatalogOptionImage({
item,
rarityBackground,
characterBackground = false,
}: {
item: AdminCatalogItem;
rarityBackground: boolean;
characterBackground?: boolean;
}) {
return (
<div
className={cn(
"relative size-8 shrink-0 overflow-hidden rounded-md",
rarityBackground && (characterBackground ? characterRarityGradientClass(item.key, item.rarity) : rarityGradientClass(item.rarity)),
)}
>
<Image src={item.imageUrl} alt="" fill sizes="32px" className="object-contain" />
</div>
);
}