349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import { CircleHelpIcon } from "lucide-react";
|
|
import type { ReactNode } from "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;
|
|
placeholderIcon?: ReactNode;
|
|
};
|
|
|
|
function CatalogSlotSelect<Item extends AdminCatalogItem>({
|
|
items,
|
|
value,
|
|
onValueChange,
|
|
label,
|
|
placeholder,
|
|
emptyLabel = "ไม่พบรายการ",
|
|
invalid = false,
|
|
rarityBackground = true,
|
|
characterBackground = false,
|
|
searchNames = (item) => [item.name, ...(item.aliases ?? [])],
|
|
className,
|
|
placeholderIcon,
|
|
}: 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" />
|
|
) : (
|
|
<span className="flex size-full items-center justify-center text-muted-foreground">
|
|
{placeholderIcon ?? <CircleHelpIcon className="size-8" aria-hidden="true" />}
|
|
</span>
|
|
)}
|
|
</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;
|
|
placeholderIcon?: ReactNode;
|
|
};
|
|
|
|
function DomainCatalogSelect<Item extends AdminCatalogItem>({
|
|
presentation = "dropdown",
|
|
label,
|
|
className,
|
|
showSelectedImage,
|
|
placeholderIcon,
|
|
rarityBackground = true,
|
|
...props
|
|
}: DomainSelectProps<Item>) {
|
|
if (presentation === "slot") {
|
|
return (
|
|
<CatalogSlotSelect
|
|
{...props}
|
|
rarityBackground={rarityBackground}
|
|
label={label ?? props.placeholder}
|
|
className={className}
|
|
placeholderIcon={placeholderIcon}
|
|
/>
|
|
);
|
|
}
|
|
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>
|
|
);
|
|
}
|