feat : update
This commit is contained in:
@@ -5,9 +5,9 @@ import { useMemo } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import {
|
||||
ImageCatalogPicker,
|
||||
type ImageCatalogCharacter,
|
||||
} from "@/components/admin/image-generator-fields";
|
||||
CharacterSelect,
|
||||
type AdminCatalogCharacter,
|
||||
} from "@/components/admin/ui/catalog-select";
|
||||
import { useImageGeneratorDraft } from "@/components/admin/use-image-generator-draft";
|
||||
import {
|
||||
ConstellationImage,
|
||||
@@ -77,7 +77,7 @@ function validPercent(value: string): boolean {
|
||||
export function ConstellationImageGenerator({
|
||||
characters,
|
||||
}: {
|
||||
characters: ImageCatalogCharacter[];
|
||||
characters: AdminCatalogCharacter[];
|
||||
}) {
|
||||
const [draft, setDraft, resetDraft] = useImageGeneratorDraft(
|
||||
"buzz-sheet:admin-image:constellation",
|
||||
@@ -133,12 +133,12 @@ export function ConstellationImageGenerator({
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!character}>
|
||||
<FieldLabel>ตัวละคร</FieldLabel>
|
||||
<ImageCatalogPicker
|
||||
<CharacterSelect
|
||||
items={characters}
|
||||
value={draft.characterKey}
|
||||
invalid={!character}
|
||||
placeholder="ค้นหาตัวละคร…"
|
||||
onChange={(characterKey) => setDraft((current) => ({ ...current, characterKey }))}
|
||||
onValueChange={(characterKey) => setDraft((current) => ({ ...current, characterKey }))}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
|
||||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||||
|
||||
type DeleteConditionDialogProps = Pick<
|
||||
ComponentProps<typeof DeleteConfirmationDialog>,
|
||||
"trigger" | "onConfirm"
|
||||
> & {
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export function DeleteConditionDialog({
|
||||
trigger,
|
||||
onConfirm,
|
||||
iconOnly = false,
|
||||
}: DeleteConditionDialogProps) {
|
||||
return (
|
||||
<DeleteConfirmationDialog
|
||||
trigger={trigger}
|
||||
triggerTooltip={iconOnly ? "ลบ Condition นี้" : undefined}
|
||||
title="ลบ Condition นี้?"
|
||||
description="ข้อมูลทั้งหมดใน Condition นี้จะถูกลบออก"
|
||||
showSuccessToast
|
||||
onConfirm={onConfirm}
|
||||
>
|
||||
<Trash2Icon data-icon={iconOnly ? undefined : "inline-start"} />
|
||||
<span className={iconOnly ? "sr-only" : undefined}>ลบ Condition นี้</span>
|
||||
</DeleteConfirmationDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmLabel?: ReactNode;
|
||||
trigger?: ComponentProps<typeof AlertDialogTrigger>["render"];
|
||||
children?: ReactNode;
|
||||
open?: ComponentProps<typeof AlertDialog>["open"];
|
||||
onOpenChange?: ComponentProps<typeof AlertDialog>["onOpenChange"];
|
||||
showSuccessToast?: boolean;
|
||||
triggerTooltip?: ReactNode;
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
confirmLabel = "ยืนยันลบ",
|
||||
trigger,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
showSuccessToast = true,
|
||||
triggerTooltip,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
if (showSuccessToast) toast.success("ลบสำเร็จ!");
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
{trigger ? (
|
||||
triggerTooltip ? (
|
||||
<SimpleTooltip text={triggerTooltip}>
|
||||
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
|
||||
</SimpleTooltip>
|
||||
) : (
|
||||
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
|
||||
)
|
||||
) : null}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
export { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
||||
|
||||
@@ -9,10 +9,10 @@ import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { addGlossaryAlias, removeGlossaryAlias } from "@/app/admin/actions";
|
||||
import { CatalogDropdown } from "@/components/admin/ui/catalog-select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
@@ -106,32 +106,14 @@ export function GlossaryManager({
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>รายการ</FieldLabel>
|
||||
<FuzzyCombobox
|
||||
<CatalogDropdown
|
||||
items={catalogs[kind]}
|
||||
searchNames={(value) => [value.name, ...aliases.filter((entry) => entry.kind === kind && entry.catalogKey === value.key).map((entry) => entry.alias)]}
|
||||
value={item}
|
||||
onValueChange={setItem}
|
||||
itemToStringLabel={(value) => value.name}
|
||||
itemToStringValue={(value) => value.name}
|
||||
>
|
||||
<ComboboxInput placeholder="ค้นหารายการ…" showClear />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(catalogItem) => (
|
||||
<ComboboxItem key={catalogItem.key} value={catalogItem}>
|
||||
<span className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md bg-muted",
|
||||
rarityGradientClass(catalogItem.rarity),
|
||||
)}>
|
||||
<Image src={catalogItem.imageUrl} alt="" fill className="object-contain" />
|
||||
</span>
|
||||
{catalogItem.name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
value={item?.key ?? ""}
|
||||
onValueChange={(key) => setItem(catalogs[kind].find((value) => value.key === key) ?? null)}
|
||||
placeholder="ค้นหารายการ…"
|
||||
rarityBackground
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="glossary-alias">Shortcut</FieldLabel>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
ClipboardCopyIcon,
|
||||
ClipboardPasteIcon,
|
||||
CopyIcon,
|
||||
CircleHelpIcon,
|
||||
GripVerticalIcon,
|
||||
HeartPulseIcon,
|
||||
PlusIcon,
|
||||
@@ -53,8 +52,14 @@ import {
|
||||
saveWeaponSection,
|
||||
} from "@/app/admin/actions";
|
||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
||||
import { DeleteConditionDialog } from "@/components/admin/delete-condition-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
||||
import {
|
||||
ArtifactSelect,
|
||||
CatalogDropdown,
|
||||
CharacterSelect,
|
||||
WeaponSelect,
|
||||
} from "@/components/admin/ui/catalog-select";
|
||||
import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select";
|
||||
import { MediaUploader } from "@/components/admin/media-uploader";
|
||||
import { ScrollableNav } from "@/components/public/scrollable-nav";
|
||||
import { LunarisText } from "@/components/public/lunaris-text";
|
||||
@@ -88,13 +93,11 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
FuzzyCombobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from "@/components/ui/combobox";
|
||||
import {
|
||||
Empty,
|
||||
@@ -211,8 +214,6 @@ type CatalogItem = {
|
||||
element?: string | null;
|
||||
elementIconUrl?: string | null;
|
||||
};
|
||||
const catalogItemsEqual = (item: CatalogItem, value: CatalogItem) =>
|
||||
item.key === value.key;
|
||||
const teamSearchTargetLabels: Record<TeamSearchTarget, string> = {
|
||||
all: "ทั้งหมด",
|
||||
team: "ชื่อทีม",
|
||||
@@ -468,271 +469,6 @@ export interface GuideSectionEditorProps {
|
||||
const SELECTED_TOGGLE_CLASS =
|
||||
"aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30";
|
||||
|
||||
function CatalogPicker({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rarityBackground = false,
|
||||
showSelectedImage = false,
|
||||
invalid = false,
|
||||
}: {
|
||||
items: CatalogItem[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
rarityBackground?: boolean;
|
||||
showSelectedImage?: boolean;
|
||||
invalid?: boolean;
|
||||
}) {
|
||||
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 && 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) => onChange(next?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
showClear
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
<ComboboxContent className="min-w-[24rem]">
|
||||
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item.key} value={item}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||||
rarityBackground && rarityGradientClass(item.rarity),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{item.name}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogSlotPicker({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
rarityBackground = false,
|
||||
invalid = false,
|
||||
className,
|
||||
}: {
|
||||
items: CatalogItem[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
rarityBackground?: boolean;
|
||||
invalid?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const selected = items.find((item) => item.key === value) ?? null;
|
||||
return (
|
||||
<FuzzyCombobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
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 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&>svg]:hidden",
|
||||
rarityBackground && selected && 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>ไม่พบรายการ</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item.key} value={item}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||||
rarityBackground && rarityGradientClass(item.rarity),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{item.name}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactSlotPicker({
|
||||
items,
|
||||
values,
|
||||
onChange,
|
||||
label,
|
||||
invalid = false,
|
||||
}: {
|
||||
items: CatalogItem[];
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
label: string;
|
||||
invalid?: boolean;
|
||||
}) {
|
||||
const firstKey = values[0];
|
||||
const secondKey = values[1];
|
||||
const selected = useMemo(
|
||||
() => [firstKey, secondKey].flatMap((value) => {
|
||||
const item = items.find((candidate) => candidate.key === value);
|
||||
return item ? [item] : [];
|
||||
}),
|
||||
[firstKey, items, secondKey],
|
||||
);
|
||||
const artifactIconValue = (item: CatalogItem | 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) =>
|
||||
onChange(next.slice(0, 2).map((item) => item.key))
|
||||
}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
isItemEqualToValue={catalogItemsEqual}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={selected.length
|
||||
? `${label}: ${selected.map((item) => item.name).join(", ")}`
|
||||
: label}
|
||||
aria-invalid={invalid}
|
||||
className="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 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&>svg]:hidden"
|
||||
>
|
||||
{selected.length ? (
|
||||
<TeamArtifactIcon
|
||||
first={artifactIconValue(selected[0])}
|
||||
second={artifactIconValue(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="ค้นหาชุด Artifact…"
|
||||
showClear
|
||||
showTrigger={false}
|
||||
/>
|
||||
<ComboboxEmpty>ไม่พบชุด Artifact</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem
|
||||
key={item.key}
|
||||
value={item}
|
||||
disabled={
|
||||
selected.length >= 2 &&
|
||||
!selected.some((candidate) => candidate.key === item.key)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||||
rarityGradientClass(item.rarity),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{item.name}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
|
||||
function StatSelect({
|
||||
label,
|
||||
options,
|
||||
@@ -1299,11 +1035,16 @@ function ConditionControls({
|
||||
</SimpleTooltip>
|
||||
) : null}
|
||||
{items.length > 1 ? (
|
||||
<DeleteConditionDialog
|
||||
<DeleteConfirmationDialog
|
||||
trigger={<Button type="button" size="icon" variant="outline" aria-label="ลบ Condition นี้" />}
|
||||
triggerTooltip="ลบ Condition นี้"
|
||||
type="Condition"
|
||||
description="ข้อมูลทั้งหมดใน Condition นี้จะถูกลบออก"
|
||||
onConfirm={onDelete}
|
||||
iconOnly
|
||||
/>
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span className="sr-only">ลบ Condition นี้</span>
|
||||
</DeleteConfirmationDialog>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -1570,13 +1311,13 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) {
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-4">
|
||||
<Field className="min-w-0" data-invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}>
|
||||
<FieldLabel>อาวุธ</FieldLabel>
|
||||
<CatalogPicker
|
||||
<WeaponSelect
|
||||
items={compatibleWeapons}
|
||||
value={row.weaponKey}
|
||||
invalid={weaponIssue("groups", activeGroupIndex, "rows", rowIndex, "weaponKey")}
|
||||
placeholder="ค้นหาชื่ออาวุธ…"
|
||||
rarityBackground
|
||||
onChange={(weaponKey) =>
|
||||
onValueChange={(weaponKey) =>
|
||||
setRows(
|
||||
rows.map((item) =>
|
||||
item.clientId === row.clientId
|
||||
@@ -2081,24 +1822,24 @@ function ArtifactEditor({
|
||||
{dragHandle}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1fr_4rem_auto]">
|
||||
<CatalogPicker
|
||||
<ArtifactSelect
|
||||
items={artifacts}
|
||||
value={row.firstArtifactKey}
|
||||
invalid={artifactIssue("groups", activeArtifactGroupIndex, "tiers", groupIndex, "rows", rowIndex, "firstArtifactKey")}
|
||||
placeholder="ค้นหาชุด Artifact แรก…"
|
||||
rarityBackground
|
||||
showSelectedImage
|
||||
onChange={(firstArtifactKey) =>
|
||||
onValueChange={(firstArtifactKey) =>
|
||||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate))
|
||||
}
|
||||
/>
|
||||
<CatalogPicker
|
||||
<ArtifactSelect
|
||||
items={artifacts}
|
||||
value={row.secondArtifactKey ?? ""}
|
||||
placeholder="ค้นหาชุดที่สอง (ถ้ามี)…"
|
||||
rarityBackground
|
||||
showSelectedImage
|
||||
onChange={(secondArtifactKey) =>
|
||||
onValueChange={(secondArtifactKey) =>
|
||||
setArtifactTierRows(groupIndex, (rows) => rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate))
|
||||
}
|
||||
/>
|
||||
@@ -3496,7 +3237,7 @@ function TeamEditor({
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
title={`ลบ ${team.title || `Team ${teamIndex + 1}`}?`}
|
||||
type="Team"
|
||||
description="ข้อมูล Team และตัวละครทั้ง 4 คนจะถูกลบออกจาก Guide"
|
||||
confirmLabel="ยืนยันลบ Team"
|
||||
onConfirm={() =>
|
||||
@@ -3572,7 +3313,8 @@ function TeamEditor({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:grid sm:grid-cols-3">
|
||||
<div className="sm:col-span-2 sm:row-span-2">
|
||||
<CatalogSlotPicker
|
||||
<CharacterSelect
|
||||
presentation="slot"
|
||||
items={characters}
|
||||
value={member.characterKey}
|
||||
label={`เลือกตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||||
@@ -3584,7 +3326,7 @@ function TeamEditor({
|
||||
"characterKey",
|
||||
)}
|
||||
className="rounded-lg border-0 [&>img]:object-cover [&>img]:p-0"
|
||||
onChange={(characterKey) =>
|
||||
onValueChange={(characterKey) =>
|
||||
updateTeamMember(
|
||||
teamIndex,
|
||||
memberIndex,
|
||||
@@ -3601,7 +3343,8 @@ function TeamEditor({
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 grid-cols-2 gap-1 sm:row-span-2 sm:grid-cols-1">
|
||||
<CatalogSlotPicker
|
||||
<WeaponSelect
|
||||
presentation="slot"
|
||||
items={compatibleWeapons}
|
||||
value={member.weaponKey ?? ""}
|
||||
label={`เลือกอาวุธของตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||||
@@ -3612,7 +3355,7 @@ function TeamEditor({
|
||||
memberIndex,
|
||||
"weaponKey",
|
||||
)}
|
||||
onChange={(weaponKey) =>
|
||||
onValueChange={(weaponKey) =>
|
||||
updateTeamMember(
|
||||
teamIndex,
|
||||
memberIndex,
|
||||
@@ -3624,7 +3367,8 @@ function TeamEditor({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ArtifactSlotPicker
|
||||
<ArtifactSelect
|
||||
multiple
|
||||
items={artifacts}
|
||||
values={[
|
||||
member.artifactKey,
|
||||
@@ -3638,7 +3382,8 @@ function TeamEditor({
|
||||
memberIndex,
|
||||
"artifactKey",
|
||||
)}
|
||||
onChange={(artifactKeys) =>
|
||||
placeholder="ค้นหาชุด Artifact…"
|
||||
onValuesChange={(artifactKeys) =>
|
||||
updateTeamMember(
|
||||
teamIndex,
|
||||
memberIndex,
|
||||
@@ -3654,82 +3399,40 @@ function TeamEditor({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 gap-1">
|
||||
<Select
|
||||
<ConstellationSelect
|
||||
value={String(member.constellation)}
|
||||
includeRange
|
||||
disabled={!character}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null)
|
||||
updateTeamMember(
|
||||
teamIndex,
|
||||
memberIndex,
|
||||
{
|
||||
constellation: value,
|
||||
},
|
||||
);
|
||||
onValueChange={(value) =>
|
||||
updateTeamMember(
|
||||
teamIndex,
|
||||
memberIndex,
|
||||
{ constellation: value },
|
||||
)
|
||||
}
|
||||
aria-label={`Constellation ของตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||||
triggerProps={{
|
||||
size: "sm",
|
||||
className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden",
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden"
|
||||
aria-label={`Constellation ของตัวละครตำแหน่ง ${memberIndex + 1}`}
|
||||
>
|
||||
<SelectValue>
|
||||
{teamConstellationLabel(member.constellation)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["0", "1", "2", "3", "4", "5", "6", "0-6"].map(
|
||||
(constellation) => (
|
||||
<SelectItem
|
||||
key={constellation}
|
||||
value={String(constellation)}
|
||||
>
|
||||
{teamConstellationLabel(constellation)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
/>
|
||||
<RefinementSelect
|
||||
value={String(member.refinement)}
|
||||
includeRange
|
||||
disabled={!selectedWeapon}
|
||||
onValueChange={(value) => {
|
||||
if (value !== null)
|
||||
updateTeamMember(
|
||||
teamIndex,
|
||||
memberIndex,
|
||||
{
|
||||
refinement: value,
|
||||
},
|
||||
);
|
||||
onValueChange={(value) =>
|
||||
updateTeamMember(
|
||||
teamIndex,
|
||||
memberIndex,
|
||||
{ refinement: value },
|
||||
)
|
||||
}
|
||||
aria-label={`Refinement ของอาวุธตำแหน่ง ${memberIndex + 1}`}
|
||||
triggerProps={{
|
||||
size: "sm",
|
||||
className: "h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden",
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-6 min-w-0 flex-1 justify-center px-1 text-xs [&>svg]:hidden"
|
||||
aria-label={`Refinement ของอาวุธตำแหน่ง ${memberIndex + 1}`}
|
||||
>
|
||||
<SelectValue>
|
||||
{teamRefinementLabel(member.refinement)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{["1", "2", "3", "4", "5", "1-5"].map(
|
||||
(refinement) => (
|
||||
<SelectItem
|
||||
key={refinement}
|
||||
value={String(refinement)}
|
||||
>
|
||||
{teamRefinementLabel(refinement)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
data-invalid={memberFieldInvalid(
|
||||
@@ -4442,10 +4145,10 @@ function ExtraCatalogItemsEditor({
|
||||
</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
<div className="min-w-64 flex-1">
|
||||
<CatalogPicker
|
||||
<CatalogDropdown
|
||||
items={items}
|
||||
value={key}
|
||||
onChange={(nextKey) => {
|
||||
onValueChange={(nextKey) => {
|
||||
if (
|
||||
nextKey &&
|
||||
!value.some(
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
FuzzyCombobox,
|
||||
} from "@/components/ui/combobox";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ImageCatalogItem {
|
||||
key: string;
|
||||
name: string;
|
||||
aliases: string[];
|
||||
imageUrl: string;
|
||||
rarity: number | null;
|
||||
}
|
||||
|
||||
export interface ImageCatalogCharacter extends ImageCatalogItem {
|
||||
element: string | null;
|
||||
weaponType: string | null;
|
||||
splashImageUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ImageCatalogWeapon extends ImageCatalogItem {
|
||||
weaponType: string | null;
|
||||
}
|
||||
|
||||
export type ImageCatalogArtifact = ImageCatalogItem;
|
||||
|
||||
export function ImageCatalogPicker<Item extends ImageCatalogItem>({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
invalid = false,
|
||||
}: {
|
||||
items: Item[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
invalid?: boolean;
|
||||
}) {
|
||||
const selected = items.find((item) => item.key === value) ?? null;
|
||||
|
||||
return (
|
||||
<FuzzyCombobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(item) => onChange(item?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
searchNames={(item) => [item.name, ...item.aliases]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder={placeholder}
|
||||
showClear
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
<ComboboxContent className="min-w-72">
|
||||
<ComboboxEmpty>ไม่พบรายการ</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item.key} value={item}>
|
||||
<span
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||||
rarityGradientClass(item.rarity),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</span>
|
||||
<span>{item.name}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CharacterSelect } from "@/components/admin/ui/catalog-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -52,7 +53,6 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
FuzzyCombobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
@@ -96,7 +96,6 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import {
|
||||
accentGradient,
|
||||
detectThemeColorsFromPixels,
|
||||
@@ -1089,10 +1088,12 @@ export function OverviewImageGenerator({
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>ตัวละคร</FieldLabel>
|
||||
<FuzzyCombobox
|
||||
<CharacterSelect
|
||||
items={characters}
|
||||
value={character}
|
||||
onValueChange={(value) => {
|
||||
value={character?.key ?? ""}
|
||||
placeholder="ค้นหาตัวละคร…"
|
||||
onValueChange={(key) => {
|
||||
const value = characters.find((item) => item.key === key) ?? null;
|
||||
if (value?.element !== character?.element) {
|
||||
const [start, end] = elementGradientColors(value?.element);
|
||||
setElementColorStart(start);
|
||||
@@ -1103,37 +1104,8 @@ export function OverviewImageGenerator({
|
||||
setNameCardSize(null);
|
||||
setNameCardCropOpen(false);
|
||||
}}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput className="w-full" placeholder="ค้นหาตัวละคร…" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>ไม่พบตัวละคร</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item) => (
|
||||
<ComboboxItem key={item.key} value={item}>
|
||||
<span
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||||
rarityGradientClass(item.rarity),
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</span>
|
||||
<span>{item.name}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
emptyLabel="ไม่พบตัวละคร"
|
||||
/>
|
||||
<FieldDescription>
|
||||
ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog
|
||||
</FieldDescription>
|
||||
|
||||
@@ -3,47 +3,53 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { CheckIcon, PlusIcon, RefreshCwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useEffect, useState, useTransition, type ReactNode } from "react";
|
||||
import { GripVerticalIcon, PlusIcon, RefreshCwIcon, Trash2Icon } from "lucide-react";
|
||||
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
activateStygianScheduleAction,
|
||||
saveStygianRecommendationsAction,
|
||||
syncStygianArchiveAction,
|
||||
} from "@/app/admin/stygian/actions";
|
||||
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
||||
import {
|
||||
ArtifactSelect,
|
||||
CatalogDropdown,
|
||||
CharacterSelect,
|
||||
WeaponSelect,
|
||||
} from "@/components/admin/ui/catalog-select";
|
||||
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
||||
import {
|
||||
AdminDropdown,
|
||||
ConstellationSelect,
|
||||
ElementSelect,
|
||||
RefinementSelect,
|
||||
} from "@/components/admin/ui/option-select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
FuzzyCombobox,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Field, FieldGroup, FieldLabel, FieldSet, FieldLegend } from "@/components/ui/field";
|
||||
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||
import { stygianElements, type StygianRecommendationItemInput } from "@/lib/stygian/schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CatalogItem = { key: string; name: string; aliases: string[]; imageUrl: string; rarity: number | null };
|
||||
type Character = CatalogItem & { constellations: Array<{ level: number; name: string; iconUrl: string | null }> };
|
||||
type ScheduleSummary = { scheduleId: number; challengeName: string; version: number; isActive: boolean };
|
||||
type CatalogItem = { key: string; name: string; aliases: string[]; imageUrl: string; rarity: number | null; weaponType?: string | null };
|
||||
type Character = CatalogItem & { element: string | null; elementIconUrl: string | null };
|
||||
type ScheduleSummary = { scheduleId: number; challengeName: string };
|
||||
type Boss = { level: number; position: number; name: string; imageUrl: string };
|
||||
type StoredItem = {
|
||||
kind: string;
|
||||
catalogKey: string | null;
|
||||
weaponKey: string | null;
|
||||
artifactKey: string | null;
|
||||
characterKey: string | null;
|
||||
refinement: number | null;
|
||||
constellationLevel: number | null;
|
||||
element: string | null;
|
||||
};
|
||||
@@ -52,100 +58,24 @@ type Recommendation = { id: string; level: number; bossPosition: number; kind: s
|
||||
function toInput(item: StoredItem): StygianRecommendationItemInput | null {
|
||||
if ((item.kind === "character" || item.kind === "weapon" || item.kind === "artifact") && item.catalogKey) {
|
||||
return item.kind === "character"
|
||||
? { kind: "character", catalogKey: item.catalogKey, weaponKey: item.weaponKey, artifactKey: item.artifactKey, constellationLevel: item.constellationLevel }
|
||||
? { kind: "character", catalogKey: item.catalogKey, weaponKey: item.weaponKey, artifactKey: item.artifactKey, constellationLevel: item.constellationLevel, refinement: item.refinement }
|
||||
: { kind: item.kind, catalogKey: item.catalogKey };
|
||||
}
|
||||
if (item.kind === "element" && item.element && stygianElements.includes(item.element as typeof stygianElements[number])) {
|
||||
return { kind: "element", element: item.element as typeof stygianElements[number] };
|
||||
}
|
||||
if (item.kind === "constellation" && item.characterKey && item.constellationLevel) {
|
||||
return { kind: "constellation", characterKey: item.characterKey, constellationLevel: item.constellationLevel };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function label(item: StygianRecommendationItemInput, characters: Character[], weapons: CatalogItem[], artifacts: CatalogItem[]) {
|
||||
if (item.kind === "element") return item.element;
|
||||
if (item.kind === "constellation") return `${characters.find((entry) => entry.key === item.characterKey)?.name ?? item.characterKey} C${item.constellationLevel}`;
|
||||
const values = item.kind === "character" ? characters : item.kind === "weapon" ? weapons : artifacts;
|
||||
return values.find((entry) => entry.key === item.catalogKey)?.name ?? item.catalogKey;
|
||||
}
|
||||
|
||||
function CatalogPicker({ items, value, onChange, placeholder }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string }) {
|
||||
const selected = items.find((item) => item.key === value) ?? null;
|
||||
return (
|
||||
<FuzzyCombobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
searchNames={(item) => [item.name, ...item.aliases]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput className="w-full" placeholder={placeholder} showClear />
|
||||
<ComboboxContent className="min-w-80">
|
||||
<ComboboxEmpty>No matches</ComboboxEmpty>
|
||||
<ComboboxList>{(item) => <ComboboxItem key={item.key} value={item}><Image src={item.imageUrl} alt="" width={32} height={32} className="size-8 object-contain" /><span>{item.name}</span></ComboboxItem>}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogSlotPicker({ items, value, onChange, label, placeholder, className }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; label: string; placeholder: string; className?: string }) {
|
||||
const selected = items.find((item) => item.key === value) ?? null;
|
||||
return (
|
||||
<FuzzyCombobox items={items} value={selected} onValueChange={(next) => onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} searchNames={(item) => [item.name, ...item.aliases]} autoHighlight>
|
||||
<ComboboxTrigger aria-label={selected ? `${label}: ${selected.name}` : label} 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 [&>svg]:hidden", selected && rarityGradientClass(selected.rarity), className)}>
|
||||
{selected ? <Image src={selected.imageUrl} alt="" fill sizes="96px" className="object-contain p-0.5" /> : <PlusIcon aria-hidden="true" />}
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent className="min-w-72"><ComboboxInput placeholder={placeholder} showClear showTrigger={false} /><ComboboxEmpty>No matches</ComboboxEmpty><ComboboxList>{(item) => <ComboboxItem key={item.key} value={item}><div className={cn("relative size-8 shrink-0 overflow-hidden rounded-md", rarityGradientClass(item.rarity))}><Image src={item.imageUrl} alt="" fill sizes="32px" className="object-contain" /></div><span>{item.name}</span></ComboboxItem>}</ComboboxList></ComboboxContent>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemPicker({ characters, weapons, artifacts, onAdd }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
||||
const [kind, setKind] = useState<StygianRecommendationItemInput["kind"]>("character");
|
||||
const [key, setKey] = useState("");
|
||||
const [weaponKey, setWeaponKey] = useState("");
|
||||
const [artifactKey, setArtifactKey] = useState("");
|
||||
const [element, setElement] = useState<typeof stygianElements[number]>("Cryo");
|
||||
const [constellationLevel, setConstellationLevel] = useState(0);
|
||||
const catalog = kind === "character" || kind === "constellation" ? characters : kind === "weapon" ? weapons : artifacts;
|
||||
const selected = catalog.find((item) => item.key === key) ?? null;
|
||||
const selectedWeapon = weapons.find((item) => item.key === weaponKey) ?? null;
|
||||
const selectedArtifact = artifacts.find((item) => item.key === artifactKey) ?? null;
|
||||
const value: StygianRecommendationItemInput | null = kind === "element"
|
||||
? { kind, element }
|
||||
: kind === "constellation"
|
||||
? key ? { kind, characterKey: key, constellationLevel } : null
|
||||
: key ? kind === "character"
|
||||
? { kind, catalogKey: key, weaponKey: weaponKey || null, artifactKey: artifactKey || null, constellationLevel: constellationLevel || null }
|
||||
: { kind, catalogKey: key }
|
||||
: null;
|
||||
const catalog = kind === "character" ? characters : kind === "weapon" ? weapons : artifacts;
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<div className="self-start rounded-xl border bg-muted/30 p-1.5">
|
||||
<div className={`relative aspect-square overflow-hidden rounded-lg ${selected ? rarityGradientClass(selected.rarity) : "bg-muted"}`}>
|
||||
{selected ? <Image src={selected.imageUrl} alt={selected.name} fill sizes="112px" className="object-contain" /> : <span className="absolute inset-0 grid place-items-center px-2 text-center text-xs text-muted-foreground">{kind === "element" ? element : "Choose an item"}</span>}
|
||||
</div>
|
||||
{kind === "character" ? <div className="mt-1.5 grid grid-cols-2 gap-1.5">
|
||||
<div className={`relative aspect-square overflow-hidden rounded-md border ${selectedArtifact ? rarityGradientClass(selectedArtifact.rarity) : "bg-muted"}`}>{selectedArtifact ? <Image src={selectedArtifact.imageUrl} alt={selectedArtifact.name} fill sizes="48px" className="object-contain p-1" /> : null}</div>
|
||||
<div className={`relative aspect-square overflow-hidden rounded-md border ${selectedWeapon ? rarityGradientClass(selectedWeapon.rarity) : "bg-muted"}`}>{selectedWeapon ? <Image src={selectedWeapon.imageUrl} alt={selectedWeapon.name} fill sizes="48px" className="object-contain p-1" /> : null}</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
<FieldGroup className="gap-3">
|
||||
<div className="grid gap-3 sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||
<Field><FieldLabel>Type</FieldLabel><Select value={kind} onValueChange={(next) => { if (next) { setKind(next as typeof kind); setKey(""); setConstellationLevel(next === "constellation" ? 1 : 0); } }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="character">Character</SelectItem><SelectItem value="element">Element</SelectItem><SelectItem value="weapon">Weapon</SelectItem><SelectItem value="artifact">Artifact</SelectItem><SelectItem value="constellation">Constellation</SelectItem></SelectGroup></SelectContent></Select></Field>
|
||||
<Field><FieldLabel>Recommendation</FieldLabel>{kind === "element" ? <Select value={element} onValueChange={(next) => { if (next) setElement(next as typeof element); }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{stygianElements.map((entry) => <SelectItem key={entry} value={entry}>{entry}</SelectItem>)}</SelectGroup></SelectContent></Select> : <CatalogPicker items={catalog} value={key} onChange={setKey} placeholder={`Search ${kind}…`} />}</Field>
|
||||
</div>
|
||||
{kind === "character" ? <div className="grid gap-3 sm:grid-cols-2"><Field><FieldLabel>Weapon (optional)</FieldLabel><CatalogPicker items={weapons} value={weaponKey} onChange={setWeaponKey} placeholder="Search weapon…" /></Field><Field><FieldLabel>Artifact (optional)</FieldLabel><CatalogPicker items={artifacts} value={artifactKey} onChange={setArtifactKey} placeholder="Search artifact…" /></Field></div> : null}
|
||||
<div className="flex items-end justify-end gap-3">
|
||||
{kind === "character" || kind === "constellation" ? <Field className="max-w-32"><FieldLabel>{kind === "character" ? "Constellation" : "Level"}</FieldLabel><Select value={String(constellationLevel)} onValueChange={(next) => { if (next) setConstellationLevel(Number(next)); }}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{(kind === "character" ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5, 6]).map((entry) => <SelectItem key={entry} value={String(entry)}>C{entry}</SelectItem>)}</SelectGroup></SelectContent></Select></Field> : null}
|
||||
<Button type="button" disabled={!value} onClick={() => { if (value) { onAdd(value); setKey(""); } }}><PlusIcon data-icon="inline-start" />Add</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field><FieldLabel>Type</FieldLabel><AdminDropdown value={kind} onValueChange={(next) => setKind(next as typeof kind)} triggerProps={{ className: "w-full" }} options={[{ value: "character", label: "Character" }, { value: "element", label: "Element" }, { value: "weapon", label: "Weapon" }, { value: "artifact", label: "Artifact" }]} /></Field>
|
||||
{kind === "element" ? <Field><FieldLabel>Element</FieldLabel><ElementSelect elements={stygianElements} iconUrls={Object.fromEntries(characters.flatMap((character) => character.element && character.elementIconUrl ? [[character.element, character.elementIconUrl]] : []))} placeholder="Choose element…" onValueChange={(next) => onAdd({ kind: "element", element: next as typeof stygianElements[number] })} triggerProps={{ className: "w-full" }} /></Field> : null}
|
||||
{kind !== "element" ? <Field><FieldLabel>Recommendation</FieldLabel><CatalogDropdown items={catalog} value="" onValueChange={(catalogKey) => { if (!catalogKey) return; if (kind === "character") onAdd({ kind, catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null, refinement: null }); else if (kind === "weapon") onAdd({ kind, catalogKey }); else onAdd({ kind: "artifact", catalogKey }); }} placeholder={`Search ${kind}…`} emptyLabel="No matches" rarityBackground /></Field> : null}
|
||||
</FieldGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,8 +84,8 @@ function AddRecommendationSlot({ characters, weapons, artifacts, onAdd }: { char
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger render={<Button variant="outline" className="aspect-[3/4] h-auto min-h-28 w-full border-dashed" />}><PlusIcon /><span className="sr-only">Add recommendation</span></PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[min(44rem,calc(100vw-2rem))] p-4">
|
||||
<PopoverHeader><PopoverTitle>Add recommendation</PopoverTitle><PopoverDescription>Choose a character or flexible recommendation. Weapon and artifact are optional.</PopoverDescription></PopoverHeader>
|
||||
<PopoverContent align="start" className="w-[min(22rem,calc(100vw-2rem))] p-4">
|
||||
<PopoverHeader><PopoverTitle>Add recommendation</PopoverTitle><PopoverDescription>Selecting an item adds it immediately.</PopoverDescription></PopoverHeader>
|
||||
<ItemPicker characters={characters} weapons={weapons} artifacts={artifacts} onAdd={(item) => { onAdd(item); setOpen(false); }} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -163,49 +93,74 @@ function AddRecommendationSlot({ characters, weapons, artifacts, onAdd }: { char
|
||||
}
|
||||
|
||||
function AddCharacterSlot({ characters, onAdd }: { characters: Character[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
||||
return <CatalogSlotPicker items={characters} value="" label="Choose character" placeholder="Search character…" className="rounded-lg border-dashed" onChange={(catalogKey) => { if (catalogKey) onAdd({ kind: "character", catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null }); }} />;
|
||||
return <CharacterSelect presentation="slot" items={characters} value="" label="Choose character" placeholder="Search character…" emptyLabel="No matches" className="rounded-lg border-dashed" onValueChange={(catalogKey) => { if (catalogKey) onAdd({ kind: "character", catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null, refinement: null }); }} />;
|
||||
}
|
||||
|
||||
function VisualRecommendation({ item, onChange, onRemove, characters, weapons, artifacts, teamLayout = false }: { item: StygianRecommendationItemInput; onChange: (item: StygianRecommendationItemInput) => void; onRemove: () => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean }) {
|
||||
function VisualRecommendation({ item, onChange, onRemove, characters, weapons, artifacts, teamLayout = false, position, dragHandle }: { item: StygianRecommendationItemInput; onChange: (item: StygianRecommendationItemInput) => void; onRemove: () => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean; position?: number; dragHandle?: ReactNode }) {
|
||||
const character = item.kind === "character" ? characters.find((entry) => entry.key === item.catalogKey) : null;
|
||||
const weapon = item.kind === "character" ? weapons.find((entry) => entry.key === item.weaponKey) : null;
|
||||
const artifact = item.kind === "character" ? artifacts.find((entry) => entry.key === item.artifactKey) : null;
|
||||
const standalone = item.kind === "weapon"
|
||||
? weapons.find((entry) => entry.key === item.catalogKey)
|
||||
: item.kind === "artifact"
|
||||
? artifacts.find((entry) => entry.key === item.catalogKey)
|
||||
: item.kind === "constellation"
|
||||
? characters.find((entry) => entry.key === item.characterKey)
|
||||
: null;
|
||||
const compatibleWeapons = weapons.filter((entry) => supportsWeaponType(character?.weaponType, entry.weaponType));
|
||||
const elementSource = item.kind === "element" ? characters.find((entry) => entry.element === item.element && entry.elementIconUrl) : null;
|
||||
const elementIconUrls = Object.fromEntries(characters.flatMap((entry) => entry.element && entry.elementIconUrl ? [[entry.element, entry.elementIconUrl]] : []));
|
||||
const updateCharacter = (patch: Partial<Extract<StygianRecommendationItemInput, { kind: "character" }>>) => {
|
||||
if (item.kind === "character") onChange({ ...item, ...patch });
|
||||
};
|
||||
return (
|
||||
<div className="group relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
|
||||
{character ? <>
|
||||
{teamLayout ? <div className="mb-1 flex h-7 items-center justify-between"><span className="text-xs text-muted-foreground">#{(position ?? 0) + 1}</span>{dragHandle}</div> : null}
|
||||
<div className={cn("grid gap-1", teamLayout && "grid-cols-3")}>
|
||||
<CatalogSlotPicker items={characters} value={character.key} label="Choose character" placeholder="Search character…" className={cn("rounded-lg border-0 [&>img]:p-0", teamLayout && "col-span-2 row-span-2")} onChange={(catalogKey) => { if (catalogKey) updateCharacter({ catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null }); }} />
|
||||
<CharacterSelect presentation="slot" items={characters} value={character.key} label="Choose character" placeholder="Search character…" emptyLabel="No matches" className={cn("rounded-lg border-0 [&>img]:p-0", teamLayout && "col-span-2 row-span-2")} onValueChange={(catalogKey) => { if (catalogKey) updateCharacter({ catalogKey, weaponKey: null, artifactKey: null, constellationLevel: null, refinement: null }); }} />
|
||||
{teamLayout ? <div className="grid min-w-0 grid-cols-1 gap-1">
|
||||
<CatalogSlotPicker items={weapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" onChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null })} />
|
||||
<CatalogSlotPicker items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" onChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} />
|
||||
</div> : <div className="col-span-full grid grid-cols-2 gap-1"><CatalogSlotPicker items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" onChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} /><CatalogSlotPicker items={weapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" onChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null })} /></div>}
|
||||
<WeaponSelect presentation="slot" items={compatibleWeapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" emptyLabel="No matches" onValueChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null, refinement: weaponKey ? item.kind === "character" ? item.refinement ?? 1 : 1 : null })} />
|
||||
<ArtifactSelect presentation="slot" items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" emptyLabel="No matches" onValueChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} />
|
||||
</div> : <div className="col-span-full grid grid-cols-2 gap-1"><ArtifactSelect presentation="slot" items={artifacts} value={artifact?.key ?? ""} label={`Choose artifact for ${character.name}`} placeholder="Search artifact…" emptyLabel="No matches" onValueChange={(artifactKey) => updateCharacter({ artifactKey: artifactKey || null })} /><WeaponSelect presentation="slot" items={compatibleWeapons} value={weapon?.key ?? ""} label={`Choose weapon for ${character.name}`} placeholder="Search weapon…" emptyLabel="No matches" onValueChange={(weaponKey) => updateCharacter({ weaponKey: weaponKey || null })} /></div>}
|
||||
</div>
|
||||
<div className="mt-1.5 flex min-w-0 items-center justify-between gap-1">
|
||||
<span className="truncate text-xs font-medium">{character.name}</span>
|
||||
{teamLayout && item.kind === "character" ? <Select value={item.constellationLevel === null ? "none" : String(item.constellationLevel)} onValueChange={(next) => { if (next) updateCharacter({ constellationLevel: next === "none" ? null : Number(next) }); }}><SelectTrigger size="sm" className="h-6 w-auto min-w-12 px-1 text-xs"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="none">—</SelectItem>{[0, 1, 2, 3, 4, 5, 6].map((entry) => <SelectItem key={entry} value={String(entry)}>C{entry}</SelectItem>)}</SelectGroup></SelectContent></Select> : item.kind === "character" && item.constellationLevel !== null ? <Badge variant="secondary">C{item.constellationLevel}</Badge> : null}
|
||||
</div>
|
||||
</> : <div className="flex aspect-[3/4] flex-col items-center justify-center gap-2 rounded-lg bg-muted p-2 text-center">
|
||||
{standalone ? <div className={`relative aspect-square w-full overflow-hidden rounded-lg ${rarityGradientClass(standalone.rarity)}`}><Image src={standalone.imageUrl} alt={standalone.name} fill sizes="128px" className="object-contain p-1" /></div> : null}
|
||||
<span className="text-xs font-medium">{label(item, characters, weapons, artifacts)}</span>
|
||||
{teamLayout && item.kind === "character" ? <div className="mt-1 grid grid-cols-2 gap-1">
|
||||
<ConstellationSelect includeNone value={item.constellationLevel === null ? "none" : String(item.constellationLevel)} onValueChange={(next) => updateCharacter({ constellationLevel: next === "none" ? null : Number(next) })} triggerProps={{ size: "sm", className: "h-7 min-w-0 justify-center px-1 text-xs" }} />
|
||||
<RefinementSelect includeNone disabled={!weapon} value={item.refinement === null ? "none" : String(item.refinement)} onValueChange={(next) => updateCharacter({ refinement: next === "none" ? null : Number(next) })} triggerProps={{ size: "sm", className: "h-7 min-w-0 justify-center px-1 text-xs" }} />
|
||||
</div> : <div className="mt-1.5 flex min-w-0 items-center justify-between gap-1"><span className="truncate text-xs font-medium">{character.name}</span>{item.kind === "character" && item.constellationLevel !== null ? <Badge variant="secondary">C{item.constellationLevel}</Badge> : null}</div>}
|
||||
</> : <div className="flex flex-col gap-1 rounded-lg p-1 text-center">
|
||||
{item.kind === "weapon" ? <>
|
||||
<WeaponSelect presentation="slot" items={weapons} value={item.catalogKey} label="Change weapon" placeholder="Search weapon…" emptyLabel="No matches" className="rounded-lg border-0" onValueChange={(catalogKey) => { if (catalogKey) onChange({ ...item, catalogKey }); }} />
|
||||
<span className="truncate text-xs font-medium">{weapons.find((entry) => entry.key === item.catalogKey)?.name ?? item.catalogKey}</span>
|
||||
</> : null}
|
||||
{item.kind === "artifact" ? <>
|
||||
<ArtifactSelect presentation="slot" items={artifacts} value={item.catalogKey} label="Change artifact" placeholder="Search artifact…" emptyLabel="No matches" className="rounded-lg border-0" onValueChange={(catalogKey) => { if (catalogKey) onChange({ ...item, catalogKey }); }} />
|
||||
<span className="truncate text-xs font-medium">{artifacts.find((entry) => entry.key === item.catalogKey)?.name ?? item.catalogKey}</span>
|
||||
</> : null}
|
||||
{item.kind === "element" ? <>
|
||||
{elementSource?.elementIconUrl ? <div className="relative aspect-square w-full overflow-hidden rounded-lg"><Image src={elementSource.elementIconUrl} alt={item.element} fill sizes="128px" className="object-contain p-3" style={{ filter: elementIconFilter(item.element) }} /></div> : null}
|
||||
<ElementSelect elements={stygianElements} iconUrls={elementIconUrls} value={item.element} onValueChange={(element) => onChange({ ...item, element: element as typeof stygianElements[number] })} triggerProps={{ size: "sm", className: "h-7 w-full justify-center px-1 text-xs" }} />
|
||||
</> : null}
|
||||
</div>}
|
||||
<Button variant="destructive" size="icon-xs" className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100" aria-label="Remove recommendation" onClick={onRemove}><Trash2Icon /></Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableRecommendation({ id, children }: { id: string; children: (dragHandle: ReactNode) => ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
return <div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition }} className={cn("min-w-0", isDragging && "opacity-60")}>{children(<Button type="button" variant="ghost" size="icon-xs" className="touch-none" aria-label="Drag to reorder character" {...attributes} {...listeners}><GripVerticalIcon /></Button>)}</div>;
|
||||
}
|
||||
|
||||
function VisualRecommendationGrid({ items, limit, onChange, characters, weapons, artifacts, teamLayout = false, characterOnly = false }: { items: StygianRecommendationItemInput[]; limit?: number; onChange: (items: StygianRecommendationItemInput[]) => void; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; teamLayout?: boolean; characterOnly?: boolean }) {
|
||||
const emptyCount = limit ? Math.max(0, limit - items.length) : 1;
|
||||
return <div className={cn("grid gap-2", teamLayout ? "grid-cols-[repeat(4,minmax(0,7.5rem))]" : "grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))]")}>{items.map((item, index) => <VisualRecommendation key={`${label(item, characters, weapons, artifacts)}-${index}`} item={item} teamLayout={teamLayout || characterOnly} characters={characters} weapons={weapons} artifacts={artifacts} onChange={(next) => onChange(items.map((entry, candidate) => candidate === index ? next : entry))} onRemove={() => onChange(items.filter((_, candidate) => candidate !== index))} />)}{Array.from({ length: emptyCount }, (_, index) => teamLayout || characterOnly ? <AddCharacterSlot key={`empty-${index}`} characters={characters} onAdd={(item) => onChange([...items, item])} /> : <AddRecommendationSlot key={`empty-${index}`} characters={characters} weapons={weapons} artifacts={artifacts} onAdd={(item) => onChange([...items, item])} />)}</div>;
|
||||
const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
||||
const sortableIds = items.map((item, index) => item.kind === "character" ? `character:${item.catalogKey}:${index}` : `${item.kind}:${index}`);
|
||||
const cards = <div className={cn("grid gap-2", teamLayout ? "grid-cols-[repeat(4,minmax(0,7.5rem))]" : "grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))]")}>{items.map((item, index) => {
|
||||
const card = (dragHandle?: ReactNode) => <VisualRecommendation key={sortableIds[index]} item={item} position={index} dragHandle={dragHandle} teamLayout={teamLayout || characterOnly} characters={characters} weapons={weapons} artifacts={artifacts} onChange={(next) => onChange(items.map((entry, candidate) => candidate === index ? next : entry))} onRemove={() => onChange(items.filter((_, candidate) => candidate !== index))} />;
|
||||
return teamLayout ? <SortableRecommendation key={sortableIds[index]} id={sortableIds[index]}>{card}</SortableRecommendation> : card();
|
||||
})}{Array.from({ length: emptyCount }, (_, index) => teamLayout || characterOnly ? <AddCharacterSlot key={`empty-${index}`} characters={characters} onAdd={(item) => onChange([...items, item])} /> : <AddRecommendationSlot key={`empty-${index}`} characters={characters} weapons={weapons} artifacts={artifacts} onAdd={(item) => onChange([...items, item])} />)}</div>;
|
||||
if (!teamLayout) return cards;
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = sortableIds.indexOf(String(active.id));
|
||||
const to = sortableIds.indexOf(String(over.id));
|
||||
if (from >= 0 && to >= 0) onChange(arrayMove(items, from, to));
|
||||
};
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}><SortableContext items={sortableIds} strategy={rectSortingStrategy}>{cards}</SortableContext></DndContext>;
|
||||
}
|
||||
|
||||
function BossRecommendationEditor({ scheduleId, version, level, boss, recommendations, characters, weapons, artifacts, onVersionChange }: {
|
||||
@@ -266,14 +221,24 @@ function BossRecommendationEditor({ scheduleId, version, level, boss, recommenda
|
||||
<p className="mt-1 text-sm text-muted-foreground">Level {level} recommendations</p>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<FieldSet className="gap-3"><FieldLegend>REC TEAM</FieldLegend>
|
||||
<FieldSet className="gap-3"><FieldLegend>Recommended team</FieldLegend>
|
||||
{teams.map((team, teamIndex) => <Card key={teamIndex} size="sm"><CardContent className="flex flex-col gap-2">
|
||||
<VisualRecommendationGrid items={team} limit={4} teamLayout characters={characters} weapons={weapons} artifacts={artifacts} onChange={(items) => setTeams(teams.map((entry, index) => index === teamIndex ? items : entry).filter((entry) => entry.length))} />
|
||||
<Button variant="outline" size="sm" className="self-start" onClick={() => setTeams(teams.filter((_, index) => index !== teamIndex))}><Trash2Icon data-icon="inline-start" />Remove team</Button>
|
||||
<DeleteConfirmationDialog
|
||||
showSuccessToast={false}
|
||||
trigger={<Button type="button" variant="outline" size="sm" className="self-start" />}
|
||||
type="Team"
|
||||
description="This team and all of its character recommendations will be removed."
|
||||
confirmLabel="Remove team"
|
||||
onConfirm={() => setTeams(teams.filter((_, index) => index !== teamIndex))}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
Remove team
|
||||
</DeleteConfirmationDialog>
|
||||
</CardContent></Card>)}
|
||||
<Button variant="outline" size="sm" className="self-start" onClick={() => setTeams([...teams, []])}><PlusIcon data-icon="inline-start" />Add team</Button>
|
||||
</FieldSet>
|
||||
<FieldSet className="gap-3"><FieldLegend>REC UNIT</FieldLegend><VisualRecommendationGrid items={units} characters={characters} weapons={weapons} artifacts={artifacts} onChange={setUnits} /></FieldSet>
|
||||
<FieldSet className="gap-3"><FieldLegend>Recommended unit</FieldLegend><VisualRecommendationGrid items={units} characters={characters} weapons={weapons} artifacts={artifacts} onChange={setUnits} /></FieldSet>
|
||||
<div className="flex justify-end"><AutosaveStatus state={saveState} issues={valid ? [] : ["Add at least 1 character to each team before autosave."]} onSave={() => setFailedFingerprint(null)} /></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -281,7 +246,7 @@ function BossRecommendationEditor({ scheduleId, version, level, boss, recommenda
|
||||
);
|
||||
}
|
||||
|
||||
export function StygianEditor({ schedules, schedule, bosses, recommendations, characters, weapons, artifacts }: { schedules: ScheduleSummary[]; schedule: ScheduleSummary; bosses: Boss[]; recommendations: Recommendation[]; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[] }) {
|
||||
export function StygianEditor({ schedules, schedule, bosses, recommendations, characters, weapons, artifacts }: { schedules: ScheduleSummary[]; schedule: ScheduleSummary & { version: number }; bosses: Boss[]; recommendations: Recommendation[]; characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[] }) {
|
||||
const router = useRouter();
|
||||
const [version, setVersion] = useState(schedule.version);
|
||||
const [level, setLevel] = useState<105 | 110>(110);
|
||||
@@ -289,18 +254,17 @@ export function StygianEditor({ schedules, schedule, bosses, recommendations, ch
|
||||
const [pending, startTransition] = useTransition();
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card><CardHeader><CardTitle>Schedules</CardTitle><CardDescription>Sync imports every valid schedule from 5269001. Publishing remains manual.</CardDescription></CardHeader><CardContent className="flex flex-wrap gap-2">
|
||||
{schedules.map((entry) => <Button key={entry.scheduleId} variant={entry.scheduleId === schedule.scheduleId ? "secondary" : "outline"} nativeButton={false} render={<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />}>{entry.challengeName}{entry.isActive ? " · Active" : ""}</Button>)}
|
||||
<Card><CardHeader><CardTitle>Schedules</CardTitle><CardDescription>Sync imports every valid schedule from 5269001.</CardDescription></CardHeader><CardContent className="flex flex-wrap gap-2">
|
||||
{schedules.map((entry) => <Button key={entry.scheduleId} variant={entry.scheduleId === schedule.scheduleId ? "secondary" : "outline"} nativeButton={false} render={<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />}>{entry.challengeName}</Button>)}
|
||||
<Button disabled={pending} onClick={() => startTransition(async () => { const result = await syncStygianArchiveAction(); if (result.status === "error") toast.error(result.message); else { toast.success(`Imported through ${result.imported.at(-1) ?? "none"}; stopped at ${result.stoppedAt}`); router.refresh(); } })}>{pending ? <Spinner data-icon="inline-start" /> : <RefreshCwIcon data-icon="inline-start" />}Sync archive</Button>
|
||||
<Button disabled={pending || schedule.isActive} onClick={() => startTransition(async () => { const result = await activateStygianScheduleAction(schedule.scheduleId, version); if (result.status === "error") toast.error(result.message); else { setVersion(result.version); toast.success("Schedule activated"); router.refresh(); } })}><CheckIcon data-icon="inline-start" />{schedule.isActive ? "Active" : "Activate"}</Button>
|
||||
</CardContent></Card>
|
||||
|
||||
<Tabs value={String(level)} onValueChange={(next) => setLevel(Number(next) as 105 | 110)}>
|
||||
<TabsList><TabsTrigger value="105">Level 105</TabsTrigger><TabsTrigger value="110">Level 110</TabsTrigger></TabsList>
|
||||
{([105, 110] as const).map((entry) => <TabsContent key={entry} value={String(entry)}>
|
||||
<Tabs value={String(bossPosition)} onValueChange={(next) => setBossPosition(Number(next))}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-3">
|
||||
{bosses.filter((boss) => boss.level === entry).map((boss) => <TabsTrigger key={boss.position} value={String(boss.position)} className="min-w-0 py-2"><span className="truncate">{boss.name}</span></TabsTrigger>)}
|
||||
<TabsList variant="line" className="grid h-10 w-full grid-cols-3 overflow-hidden">
|
||||
{bosses.filter((boss) => boss.level === entry).map((boss) => <TabsTrigger key={boss.position} value={String(boss.position)} className="h-full min-w-0 overflow-hidden rounded-none px-2"><span className="block min-w-0 truncate">{boss.name}</span></TabsTrigger>)}
|
||||
</TabsList>
|
||||
{bosses.filter((boss) => boss.level === entry).map((boss) => <TabsContent key={boss.position} value={String(boss.position)} className="pt-4">
|
||||
<BossRecommendationEditor
|
||||
|
||||
@@ -4,7 +4,8 @@ import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import { ImageCatalogPicker, type ImageCatalogArtifact, type ImageCatalogCharacter, type ImageCatalogWeapon } from "@/components/admin/image-generator-fields";
|
||||
import { ArtifactSelect, CharacterSelect, WeaponSelect, type AdminCatalogArtifact, type AdminCatalogCharacter, type AdminCatalogWeapon } from "@/components/admin/ui/catalog-select";
|
||||
import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select";
|
||||
import { useImageGeneratorDraft } from "@/components/admin/use-image-generator-draft";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import { TeamExportImage, type TeamExportData } from "@/components/public/team-search";
|
||||
@@ -13,10 +14,8 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
||||
import { teamConstellationLabel, teamRefinementLabel } from "@/lib/guides/team-tools";
|
||||
|
||||
const memberSchema = z.object({ characterKey: z.string(), weaponKey: z.string(), artifactKey: z.string(), secondArtifactKey: z.string(), constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]), refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]), damage: z.string() });
|
||||
const draftSchema = z.object({ title: z.string(), rotationSeconds: z.string(), combo: z.string(), members: z.array(memberSchema).max(4) });
|
||||
@@ -27,7 +26,7 @@ const INITIAL_TEAM_DRAFT: TeamDraft = { title: "", rotationSeconds: "20", combo:
|
||||
const parseTeamDraft = (value: unknown): TeamDraft | null => { const parsed = draftSchema.safeParse(value); return parsed.success ? parsed.data : null; };
|
||||
const numericValue = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||
|
||||
export function TeamImageGenerator({ characters, weapons, artifacts }: { characters: ImageCatalogCharacter[]; weapons: ImageCatalogWeapon[]; artifacts: ImageCatalogArtifact[] }) {
|
||||
export function TeamImageGenerator({ characters, weapons, artifacts }: { characters: AdminCatalogCharacter[]; weapons: AdminCatalogWeapon[]; artifacts: AdminCatalogArtifact[] }) {
|
||||
const [draft, setDraft, resetDraft] = useImageGeneratorDraft("buzz-sheet:admin-image:team", INITIAL_TEAM_DRAFT, parseTeamDraft);
|
||||
const selectedKeys = draft.members.map((member) => member.characterKey).filter(Boolean);
|
||||
const valid = Boolean(draft.title.trim() && draft.combo.trim() && numericValue(draft.rotationSeconds) > 0 && draft.members.length && draft.members.every((member) => member.characterKey && member.weaponKey && member.artifactKey && member.secondArtifactKey !== member.artifactKey && numericValue(member.damage) >= 0) && new Set(selectedKeys).size === draft.members.length && draft.members.some((member) => numericValue(member.damage) > 0));
|
||||
@@ -55,13 +54,13 @@ export function TeamImageGenerator({ characters, weapons, artifacts }: { charact
|
||||
const character = characters.find((item) => item.key === member.characterKey);
|
||||
const compatibleWeapons = weapons.filter((weapon) => supportsWeaponType(character?.weaponType, weapon.weaponType));
|
||||
return <Card key={index} size="sm"><CardHeader><CardTitle>ตัวละคร #{index + 1}</CardTitle><CardAction><Button type="button" variant="ghost" size="icon-sm" aria-label={`ลบตัวละคร ${index + 1}`} onClick={() => setDraft((current) => ({ ...current, members: current.members.filter((_, memberIndex) => memberIndex !== index) }))}><Trash2Icon /></Button></CardAction></CardHeader><CardContent><FieldGroup>
|
||||
<Field><FieldLabel>ตัวละคร</FieldLabel><ImageCatalogPicker items={characters} value={member.characterKey} placeholder="ค้นหาตัวละคร…" onChange={(characterKey) => updateMember(index, { ...emptyMember(), characterKey })} /></Field>
|
||||
<Field><FieldLabel>ตัวละคร</FieldLabel><CharacterSelect items={characters} value={member.characterKey} placeholder="ค้นหาตัวละคร…" onValueChange={(characterKey) => updateMember(index, { ...emptyMember(), characterKey })} /></Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field><FieldLabel>อาวุธ</FieldLabel><ImageCatalogPicker items={compatibleWeapons} value={member.weaponKey} placeholder="ค้นหาอาวุธที่รองรับ…" onChange={(weaponKey) => updateMember(index, { weaponKey, refinement: "1" })} /></Field>
|
||||
<Field><FieldLabel>Refinement</FieldLabel><Select value={member.refinement} onValueChange={(value) => { if (value) updateMember(index, { refinement: value as TeamMemberDraft["refinement"] }); }}><SelectTrigger><SelectValue>{teamRefinementLabel(member.refinement)}</SelectValue></SelectTrigger><SelectContent><SelectGroup>{["1", "2", "3", "4", "5", "1-5"].map((value) => <SelectItem key={value} value={value}>{teamRefinementLabel(value)}</SelectItem>)}</SelectGroup></SelectContent></Select></Field>
|
||||
<Field><FieldLabel>Artifact</FieldLabel><ImageCatalogPicker items={artifacts} value={member.artifactKey} placeholder="ค้นหา Artifact…" onChange={(artifactKey) => updateMember(index, { artifactKey })} /></Field>
|
||||
<Field><FieldLabel>Artifact ชุดที่ 2 (ถ้ามี)</FieldLabel><ImageCatalogPicker items={artifacts.filter((item) => item.key !== member.artifactKey)} value={member.secondArtifactKey} placeholder="ค้นหา Artifact…" onChange={(secondArtifactKey) => updateMember(index, { secondArtifactKey })} /></Field>
|
||||
<Field><FieldLabel>Constellation</FieldLabel><Select value={member.constellation} onValueChange={(value) => { if (value) updateMember(index, { constellation: value as TeamMemberDraft["constellation"] }); }}><SelectTrigger><SelectValue>{teamConstellationLabel(member.constellation)}</SelectValue></SelectTrigger><SelectContent><SelectGroup>{["0", "1", "2", "3", "4", "5", "6", "0-6"].map((value) => <SelectItem key={value} value={value}>{teamConstellationLabel(value)}</SelectItem>)}</SelectGroup></SelectContent></Select></Field>
|
||||
<Field><FieldLabel>อาวุธ</FieldLabel><WeaponSelect items={compatibleWeapons} value={member.weaponKey} placeholder="ค้นหาอาวุธที่รองรับ…" onValueChange={(weaponKey) => updateMember(index, { weaponKey, refinement: "1" })} /></Field>
|
||||
<Field><FieldLabel>Refinement</FieldLabel><RefinementSelect includeRange value={member.refinement} onValueChange={(value) => updateMember(index, { refinement: value as TeamMemberDraft["refinement"] })} triggerProps={{ className: "w-full" }} /></Field>
|
||||
<Field><FieldLabel>Artifact</FieldLabel><ArtifactSelect items={artifacts} value={member.artifactKey} placeholder="ค้นหา Artifact…" onValueChange={(artifactKey) => updateMember(index, { artifactKey })} /></Field>
|
||||
<Field><FieldLabel>Artifact ชุดที่ 2 (ถ้ามี)</FieldLabel><ArtifactSelect items={artifacts.filter((item) => item.key !== member.artifactKey)} value={member.secondArtifactKey} placeholder="ค้นหา Artifact…" onValueChange={(secondArtifactKey) => updateMember(index, { secondArtifactKey })} /></Field>
|
||||
<Field><FieldLabel>Constellation</FieldLabel><ConstellationSelect includeRange value={member.constellation} onValueChange={(value) => updateMember(index, { constellation: value as TeamMemberDraft["constellation"] })} triggerProps={{ className: "w-full" }} /></Field>
|
||||
<Field><FieldLabel htmlFor={`team-image-damage-${index}`}>Damage</FieldLabel><Input id={`team-image-damage-${index}`} type="number" min="0" value={member.damage} onChange={(event) => updateMember(index, { damage: event.target.value })} /></Field>
|
||||
</div>
|
||||
</FieldGroup></CardContent></Card>;
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"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 { 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;
|
||||
searchNames?: (item: Item) => string[];
|
||||
};
|
||||
|
||||
type CatalogDropdownProps<Item extends AdminCatalogItem> = CommonSelectProps<Item> & {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
showSelectedImage?: boolean;
|
||||
};
|
||||
|
||||
export function CatalogDropdown<Item extends AdminCatalogItem>({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
emptyLabel = "ไม่พบรายการ",
|
||||
invalid = false,
|
||||
rarityBackground = false,
|
||||
showSelectedImage = false,
|
||||
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 && 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}
|
||||
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} />
|
||||
<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,
|
||||
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 && 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} />
|
||||
<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;
|
||||
};
|
||||
|
||||
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} rarityBackground={rarityBackground} showSelectedImage={showSelectedImage} />;
|
||||
}
|
||||
|
||||
export function CharacterSelect<Item extends AdminCatalogItem>(props: DomainSelectProps<Item>) {
|
||||
return <DomainCatalogSelect {...props} />;
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
item: AdminCatalogItem;
|
||||
rarityBackground: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-8 shrink-0 overflow-hidden rounded-md",
|
||||
rarityBackground && rarityGradientClass(item.rarity),
|
||||
)}
|
||||
>
|
||||
<Image src={item.imageUrl} alt="" fill sizes="32px" className="object-contain" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SimpleTooltip } from "@/components/tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
type DeleteConfirmationDialogProps = {
|
||||
type?: string;
|
||||
title?: ReactNode;
|
||||
description: ReactNode;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
confirmLabel?: ReactNode;
|
||||
trigger?: ComponentProps<typeof AlertDialogTrigger>["render"];
|
||||
children?: ReactNode;
|
||||
open?: ComponentProps<typeof AlertDialog>["open"];
|
||||
onOpenChange?: ComponentProps<typeof AlertDialog>["onOpenChange"];
|
||||
showSuccessToast?: boolean;
|
||||
triggerTooltip?: ReactNode;
|
||||
};
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
confirmLabel = "ยืนยันลบ",
|
||||
trigger,
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
showSuccessToast = true,
|
||||
triggerTooltip,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
if (showSuccessToast) toast.success("ลบสำเร็จ!");
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
{trigger ? (
|
||||
triggerTooltip ? (
|
||||
<SimpleTooltip text={triggerTooltip}>
|
||||
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
|
||||
</SimpleTooltip>
|
||||
) : (
|
||||
<AlertDialogTrigger render={trigger}>{children}</AlertDialogTrigger>
|
||||
)
|
||||
) : null}
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{type ? `Are you sure to delete ${type}?` : title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>ยกเลิก</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
|
||||
export type AdminSelectOption = {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
type AdminDropdownProps = {
|
||||
options: readonly AdminSelectOption[];
|
||||
value?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
triggerProps?: Omit<ComponentProps<typeof SelectTrigger>, "children">;
|
||||
};
|
||||
|
||||
export function AdminDropdown({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
triggerProps,
|
||||
}: AdminDropdownProps) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next !== null) onValueChange(next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={ariaLabel} {...triggerProps}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type ConstellationSelectProps = Omit<AdminDropdownProps, "options"> & {
|
||||
includeNone?: boolean;
|
||||
includeRange?: boolean;
|
||||
minimum?: 0 | 1;
|
||||
};
|
||||
|
||||
export function ConstellationSelect({
|
||||
includeNone = false,
|
||||
includeRange = false,
|
||||
minimum = 0,
|
||||
...props
|
||||
}: ConstellationSelectProps) {
|
||||
const options: AdminSelectOption[] = [
|
||||
...(includeNone ? [{ value: "none", label: "None" }] : []),
|
||||
...Array.from({ length: 7 - minimum }, (_, index) => {
|
||||
const level = index + minimum;
|
||||
return { value: String(level), label: `C${level}` };
|
||||
}),
|
||||
...(includeRange ? [{ value: "0-6", label: "C0-6" }] : []),
|
||||
];
|
||||
return <AdminDropdown {...props} options={options} />;
|
||||
}
|
||||
|
||||
type RefinementSelectProps = Omit<AdminDropdownProps, "options"> & {
|
||||
includeNone?: boolean;
|
||||
includeRange?: boolean;
|
||||
};
|
||||
|
||||
export function RefinementSelect({
|
||||
includeNone = false,
|
||||
includeRange = false,
|
||||
...props
|
||||
}: RefinementSelectProps) {
|
||||
const options: AdminSelectOption[] = [
|
||||
...(includeNone ? [{ value: "none", label: "None" }] : []),
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
value: String(index + 1),
|
||||
label: `R${index + 1}`,
|
||||
})),
|
||||
...(includeRange ? [{ value: "1-5", label: "R1-5" }] : []),
|
||||
];
|
||||
return <AdminDropdown {...props} options={options} />;
|
||||
}
|
||||
|
||||
type ElementSelectProps = Omit<AdminDropdownProps, "options"> & {
|
||||
elements: readonly string[];
|
||||
iconUrls?: Record<string, string | null | undefined>;
|
||||
};
|
||||
|
||||
export function ElementSelect({ elements, iconUrls = {}, ...props }: ElementSelectProps) {
|
||||
return (
|
||||
<AdminDropdown
|
||||
{...props}
|
||||
options={elements.map((element) => ({
|
||||
value: element,
|
||||
label: (
|
||||
<>
|
||||
{iconUrls[element] ? (
|
||||
<Image
|
||||
src={iconUrls[element]}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
style={{ filter: elementIconFilter(element) }}
|
||||
/>
|
||||
) : null}
|
||||
{element}
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,11 @@ import { useMemo } from "react";
|
||||
import * as z from "zod";
|
||||
|
||||
import {
|
||||
ImageCatalogPicker,
|
||||
type ImageCatalogCharacter,
|
||||
type ImageCatalogWeapon,
|
||||
} from "@/components/admin/image-generator-fields";
|
||||
CharacterSelect,
|
||||
WeaponSelect,
|
||||
type AdminCatalogCharacter,
|
||||
type AdminCatalogWeapon,
|
||||
} from "@/components/admin/ui/catalog-select";
|
||||
import { useImageGeneratorDraft } from "@/components/admin/use-image-generator-draft";
|
||||
import { GuideImageExport } from "@/components/public/guide-image-export";
|
||||
import {
|
||||
@@ -101,8 +102,8 @@ export function WeaponImageGenerator({
|
||||
characters,
|
||||
weapons,
|
||||
}: {
|
||||
characters: ImageCatalogCharacter[];
|
||||
weapons: ImageCatalogWeapon[];
|
||||
characters: AdminCatalogCharacter[];
|
||||
weapons: AdminCatalogWeapon[];
|
||||
}) {
|
||||
const [draft, setDraft, resetDraft] = useImageGeneratorDraft(
|
||||
"buzz-sheet:admin-image:weapon",
|
||||
@@ -188,12 +189,12 @@ export function WeaponImageGenerator({
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!character}>
|
||||
<FieldLabel>ตัวละคร</FieldLabel>
|
||||
<ImageCatalogPicker
|
||||
<CharacterSelect
|
||||
items={characters}
|
||||
value={draft.characterKey}
|
||||
invalid={!character}
|
||||
placeholder="ค้นหาตัวละคร…"
|
||||
onChange={(characterKey) => setDraft((current) => ({
|
||||
onValueChange={(characterKey) => setDraft((current) => ({
|
||||
...current,
|
||||
characterKey,
|
||||
rows: current.rows.map((row) => ({ ...row, weaponKey: "" })),
|
||||
@@ -240,12 +241,12 @@ export function WeaponImageGenerator({
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!row.weaponKey}>
|
||||
<FieldLabel>อาวุธ</FieldLabel>
|
||||
<ImageCatalogPicker
|
||||
<WeaponSelect
|
||||
items={compatibleWeapons}
|
||||
value={row.weaponKey}
|
||||
invalid={!row.weaponKey}
|
||||
placeholder="ค้นหาอาวุธ…"
|
||||
onChange={(weaponKey) => updateRow(row.id, { weaponKey })}
|
||||
onValueChange={(weaponKey) => updateRow(row.id, { weaponKey })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
|
||||
@@ -6,23 +6,20 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LunarisText } from "@/components/public/lunaris-text";
|
||||
import { parseLunarisText } from "@/lib/catalog/lunaris";
|
||||
import { elementIconFilter } from "@/lib/catalog/element";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import type { getActiveCatalog } from "@/lib/guides/queries";
|
||||
import type { getActiveStygianSchedule } from "@/lib/stygian/repository";
|
||||
import type { getStygianSchedule } from "@/lib/stygian/repository";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Schedule = NonNullable<Awaited<ReturnType<typeof getActiveStygianSchedule>>>;
|
||||
type Schedule = NonNullable<Awaited<ReturnType<typeof getStygianSchedule>>>;
|
||||
type Catalog = NonNullable<Awaited<ReturnType<typeof getActiveCatalog>>>;
|
||||
type Item = Schedule["recommendations"][number]["items"][number];
|
||||
|
||||
function recommendation(item: Item, catalog: Catalog) {
|
||||
if (item.kind === "element" && item.element) {
|
||||
const source = catalog.characters.find((character) => character.element === item.element && character.elementIconUrl);
|
||||
return { name: item.element, imageUrl: source?.elementIconUrl ?? null, badge: "Element" };
|
||||
}
|
||||
if (item.kind === "constellation" && item.characterKey && item.constellationLevel) {
|
||||
const character = catalog.characters.find((candidate) => candidate.key === item.characterKey);
|
||||
const constellation = character?.constellations.find((candidate) => candidate.level === item.constellationLevel);
|
||||
return { name: `${character?.name ?? item.characterKey} C${item.constellationLevel}`, imageUrl: constellation?.iconUrl ?? character?.imageUrl ?? null, badge: "Constellation" };
|
||||
return { name: item.element, imageUrl: source?.elementIconUrl ?? null, badge: "Element", rarity: null };
|
||||
}
|
||||
if (item.kind === "character") {
|
||||
const character = catalog.characters.find((candidate) => candidate.key === item.catalogKey);
|
||||
@@ -35,6 +32,7 @@ function recommendation(item: Item, catalog: Catalog) {
|
||||
rarity: character?.rarity ?? null,
|
||||
weapon,
|
||||
artifact,
|
||||
refinement: item.refinement,
|
||||
};
|
||||
}
|
||||
const collection = item.kind === "weapon" ? catalog.weapons : catalog.artifacts;
|
||||
@@ -45,29 +43,26 @@ function recommendation(item: Item, catalog: Catalog) {
|
||||
function RecommendationIcon({ item, catalog, teamLayout = false }: { item: Item; catalog: Catalog; teamLayout?: boolean }) {
|
||||
const value = recommendation(item, catalog);
|
||||
if (item.kind === "character") {
|
||||
const hasEquipment = Boolean(value.weapon || value.artifact);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1 rounded-lg border bg-card p-1">
|
||||
<div className={teamLayout ? "grid grid-cols-3 gap-1" : "flex flex-col gap-1"}>
|
||||
<div className={`${teamLayout ? "col-span-2 row-span-2" : ""} relative aspect-square overflow-hidden rounded-lg ${rarityGradientClass(value.rarity)}`}>
|
||||
<div className={teamLayout && hasEquipment ? "grid grid-cols-3 gap-1" : "flex flex-col gap-1"}>
|
||||
<div className={`${teamLayout && hasEquipment ? "col-span-2 row-span-2" : ""} relative aspect-square overflow-hidden rounded-lg ${rarityGradientClass(value.rarity)}`}>
|
||||
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="128px" className="object-contain" /> : null}
|
||||
</div>
|
||||
<div className={teamLayout ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-1"}>
|
||||
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
|
||||
{value.weapon ? <Image src={value.weapon.imageUrl} alt={value.weapon.name} fill sizes="64px" className="object-contain p-1" /> : null}
|
||||
</div>
|
||||
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
|
||||
{value.artifact ? <Image src={value.artifact.imageUrl} alt={value.artifact.name} fill sizes="64px" className="object-contain p-1" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{hasEquipment ? <div className={teamLayout ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-1"}>
|
||||
{value.weapon ? <div className={cn("relative aspect-square overflow-hidden rounded-md border", rarityGradientClass(value.weapon.rarity))}><Image src={value.weapon.imageUrl} alt={value.weapon.name} fill sizes="64px" className="object-contain p-1" /></div> : null}
|
||||
{value.artifact ? <div className={cn("relative aspect-square overflow-hidden rounded-md border", rarityGradientClass(value.artifact.rarity))}><Image src={value.artifact.imageUrl} alt={value.artifact.name} fill sizes="64px" className="object-contain p-1" /></div> : null}
|
||||
</div> : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-1"><span className="truncate text-xs font-medium" title={value.name}>{value.name}</span><Badge variant="secondary">{value.badge}</Badge></div>
|
||||
{teamLayout ? <div className="grid grid-cols-2 gap-1"><Badge variant="secondary" className="justify-center">{value.badge === "Character" ? "C—" : value.badge}</Badge><Badge variant="secondary" className="justify-center">{value.weapon && value.refinement ? `R${value.refinement}` : "R—"}</Badge></div> : <div className="flex min-w-0 items-center justify-between gap-1"><span className="truncate text-xs font-medium" title={value.name}>{value.name}</span><Badge variant="secondary">{value.badge}</Badge></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center gap-1 text-center">
|
||||
<div className="relative size-16 overflow-hidden rounded-lg border bg-muted">
|
||||
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="64px" className="object-contain" /> : null}
|
||||
<div className={cn("relative size-16 overflow-hidden rounded-lg border", item.kind !== "element" && rarityGradientClass(value.rarity))}>
|
||||
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="64px" className="object-contain" style={item.kind === "element" && item.element ? { filter: elementIconFilter(item.element) } : undefined} /> : null}
|
||||
</div>
|
||||
<span className="max-w-20 truncate text-xs font-medium" title={value.name}>{value.name}</span>
|
||||
<Badge variant="outline" className="capitalize">{value.badge}</Badge>
|
||||
@@ -109,7 +104,7 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule;
|
||||
))}
|
||||
</Accordion>
|
||||
<section className="flex flex-col gap-3" aria-label={`Recommended teams for ${boss.name}`}>
|
||||
<h3 className="font-heading text-lg font-semibold">REC TEAM</h3>
|
||||
<h3 className="font-heading text-lg font-semibold">Recommended team</h3>
|
||||
{teams.length ? teams.map((team) => (
|
||||
<div key={team.id} className="grid grid-cols-[repeat(4,minmax(0,7.5rem))] gap-2 rounded-lg border bg-muted/20 p-2">
|
||||
{team.items.slice(0, 4).map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} teamLayout />)}
|
||||
@@ -118,7 +113,7 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule;
|
||||
)) : <p className="text-sm text-muted-foreground">No team recommendations yet.</p>}
|
||||
</section>
|
||||
<section className="flex flex-col gap-3" aria-label={`Recommended units for ${boss.name}`}>
|
||||
<h3 className="font-heading text-lg font-semibold">REC UNIT</h3>
|
||||
<h3 className="font-heading text-lg font-semibold">Recommended unit</h3>
|
||||
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))] gap-2">{units.map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} />)}</div> : <p className="text-sm text-muted-foreground">No unit recommendations yet.</p>}
|
||||
</section>
|
||||
</div>
|
||||
@@ -137,10 +132,10 @@ export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalo
|
||||
{([105, 110] as const).map((level) => (
|
||||
<TabsContent key={level} value={String(level)}>
|
||||
<Tabs defaultValue="0">
|
||||
<TabsList className="grid h-auto w-full grid-cols-3" aria-label={`Level ${level} bosses`}>
|
||||
<TabsList variant="line" className="grid h-10 w-full grid-cols-3 overflow-hidden" aria-label={`Level ${level} bosses`}>
|
||||
{[0, 1, 2].map((position) => {
|
||||
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
|
||||
return <TabsTrigger key={position} value={String(position)} className="min-w-0 py-2"><span className="truncate">{boss?.name ?? `Boss ${position + 1}`}</span></TabsTrigger>;
|
||||
return <TabsTrigger key={position} value={String(position)} className="h-full min-w-0 overflow-hidden rounded-none px-2"><span className="block min-w-0 truncate">{boss?.name ?? `Boss ${position + 1}`}</span></TabsTrigger>;
|
||||
})}
|
||||
</TabsList>
|
||||
{[0, 1, 2].map((position) => <TabsContent key={position} value={String(position)} className="pt-4"><BossCard schedule={schedule} level={level} position={position} catalog={catalog} /></TabsContent>)}
|
||||
|
||||
Reference in New Issue
Block a user