feat : update

This commit is contained in:
2026-09-30 19:34:28 +07:00 Unverified
parent 69a4d34b97
commit da3d7d84ac
30 changed files with 10724 additions and 883 deletions
@@ -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";
+7 -25
View File
@@ -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>
+63 -360
View File
@@ -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>
);
}
+8 -36
View File
@@ -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>
+97 -133
View File
@@ -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
+9 -10
View File
@@ -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>;
+329
View File
@@ -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>
);
}
+137
View File
@@ -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}
</>
),
}))}
/>
);
}
+11 -10
View File
@@ -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">
+20 -25
View File
@@ -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>)}