288 lines
24 KiB
TypeScript
288 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
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 {
|
|
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 { Field, FieldGroup, FieldLabel, FieldSet, FieldLegend } from "@/components/ui/field";
|
|
import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Spinner } from "@/components/ui/spinner";
|
|
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; 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;
|
|
refinement: number | null;
|
|
constellationLevel: number | null;
|
|
element: string | null;
|
|
};
|
|
type Recommendation = { id: string; level: number; bossPosition: number; kind: string; items: StoredItem[] };
|
|
|
|
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, 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] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ItemPicker({ characters, weapons, artifacts, onAdd }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
|
const [kind, setKind] = useState<StygianRecommendationItemInput["kind"]>("character");
|
|
const catalog = kind === "character" ? characters : kind === "weapon" ? weapons : artifacts;
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
function AddRecommendationSlot({ characters, weapons, artifacts, onAdd }: { characters: Character[]; weapons: CatalogItem[]; artifacts: CatalogItem[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
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(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>
|
|
);
|
|
}
|
|
|
|
function AddCharacterSlot({ characters, onAdd }: { characters: Character[]; onAdd: (item: StygianRecommendationItemInput) => void }) {
|
|
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, 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 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")}>
|
|
<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">
|
|
<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>
|
|
{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;
|
|
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 }: {
|
|
scheduleId: number;
|
|
version: number;
|
|
level: 105 | 110;
|
|
boss: Boss;
|
|
recommendations: Recommendation[];
|
|
characters: Character[];
|
|
weapons: CatalogItem[];
|
|
artifacts: CatalogItem[];
|
|
onVersionChange: (version: number) => void;
|
|
}) {
|
|
const groups = recommendations.filter((group) => group.level === level && group.bossPosition === boss.position);
|
|
const [teams, setTeams] = useState<StygianRecommendationItemInput[][]>(() => groups.filter((group) => group.kind === "team").map((group) => group.items.flatMap((item) => toInput(item) ?? [])));
|
|
const [units, setUnits] = useState<StygianRecommendationItemInput[]>(() => groups.filter((group) => group.kind === "unit").flatMap((group) => group.items.flatMap((item) => toInput(item) ?? [])));
|
|
const fingerprint = JSON.stringify({ teams, units });
|
|
const valid = teams.every((team) => team.length >= 1 && team.length <= 4 && team.every((item) => item.kind === "character"));
|
|
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
|
|
const [failedFingerprint, setFailedFingerprint] = useState<string | null>(null);
|
|
const [savingFingerprint, setSavingFingerprint] = useState<string | null>(null);
|
|
const saveState = !valid
|
|
? "incomplete"
|
|
: failedFingerprint === fingerprint
|
|
? "error"
|
|
: savingFingerprint !== null
|
|
? "saving"
|
|
: fingerprint === savedFingerprint
|
|
? "saved"
|
|
: "unsaved";
|
|
|
|
useEffect(() => {
|
|
if (fingerprint === savedFingerprint || !valid || fingerprint === failedFingerprint || savingFingerprint !== null) return;
|
|
const timer = window.setTimeout(async () => {
|
|
setSavingFingerprint(fingerprint);
|
|
const snapshot = JSON.parse(fingerprint) as { teams: StygianRecommendationItemInput[][]; units: StygianRecommendationItemInput[] };
|
|
const result = await saveStygianRecommendationsAction(scheduleId, { expectedVersion: version, level, bossPosition: boss.position, ...snapshot });
|
|
if (result.status === "error") {
|
|
setFailedFingerprint(fingerprint);
|
|
setSavingFingerprint(null);
|
|
toast.error(result.message);
|
|
return;
|
|
}
|
|
setSavedFingerprint(fingerprint);
|
|
setFailedFingerprint(null);
|
|
setSavingFingerprint(null);
|
|
onVersionChange(result.version);
|
|
}, 700);
|
|
return () => window.clearTimeout(timer);
|
|
}, [boss.position, failedFingerprint, fingerprint, level, onVersionChange, savedFingerprint, savingFingerprint, scheduleId, valid, version]);
|
|
|
|
return (
|
|
<Card>
|
|
<CardContent className="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]">
|
|
<aside className="self-start">
|
|
<div className="relative aspect-square overflow-hidden rounded-xl bg-muted/30"><Image src={boss.imageUrl} alt={boss.name} fill sizes="192px" className="object-contain p-3" /></div>
|
|
<h3 className="mt-3 font-heading font-medium leading-snug">{boss.name}</h3>
|
|
<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>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))} />
|
|
<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>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>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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);
|
|
const [bossPosition, setBossPosition] = useState(0);
|
|
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.</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>
|
|
</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 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
|
|
scheduleId={schedule.scheduleId}
|
|
version={version}
|
|
level={entry}
|
|
boss={boss}
|
|
recommendations={recommendations}
|
|
characters={characters}
|
|
weapons={weapons}
|
|
artifacts={artifacts}
|
|
onVersionChange={setVersion}
|
|
/>
|
|
</TabsContent>)}
|
|
</Tabs>
|
|
</TabsContent>)}
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|