Files
buzz-sheet/components/admin/stygian-editor.tsx
T
2026-10-01 00:13:38 +07:00

1158 lines
34 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,
verticalListSortingStrategy,
} 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,
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 SortableTeam({
id,
position,
children,
}: {
id: string;
position: number;
children: ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(isDragging && "relative z-10 opacity-60")}
>
<Card size="sm">
<CardContent className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Team {position + 1}</span>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="touch-none"
aria-label={`Drag to reorder team ${position + 1}`}
{...attributes}
{...listeners}
>
<GripVerticalIcon />
</Button>
</div>
{children}
</CardContent>
</Card>
</div>
);
}
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 teamSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const teamIds = teams.map((_, index) => `team-${index}`);
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>
<DndContext
sensors={teamSensors}
collisionDetection={closestCenter}
onDragEnd={({ active, over }) => {
if (!over || active.id === over.id) return;
const from = teamIds.indexOf(String(active.id));
const to = teamIds.indexOf(String(over.id));
if (from >= 0 && to >= 0)
setTeams((current) => arrayMove(current, from, to));
}}
>
<SortableContext
items={teamIds}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-3">
{teams.map((team, teamIndex) => (
<SortableTeam
key={teamIds[teamIndex]}
id={teamIds[teamIndex]}
position={teamIndex}
>
<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>
</SortableTeam>
))}
</div>
</SortableContext>
</DndContext>
<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>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{schedules.map((entry) =>
entry.scheduleId === schedule.scheduleId ? (
<Button key={entry.scheduleId} disabled variant="secondary">
{entry.challengeName}
</Button>
) : (
<Button
key={entry.scheduleId}
variant="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>
);
}