1129 lines
35 KiB
TypeScript
1129 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import Image from "@/components/ui/resilient-image";
|
|
import Link from "next/link";
|
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import {
|
|
GripVerticalIcon,
|
|
PlusIcon,
|
|
Trash2Icon,
|
|
UserRoundIcon,
|
|
} 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 } from "@/app/admin/stygian/actions";
|
|
import { AutosaveStatus } from "@/components/admin/autosave-status";
|
|
import { StygianSyncButton } from "@/components/admin/stygian-sync-button";
|
|
import {
|
|
ArtifactSelect,
|
|
CatalogDropdown,
|
|
CharacterSelect,
|
|
WeaponSelect,
|
|
} from "@/components/admin/ui/catalog-select";
|
|
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
|
import {
|
|
StygianBossCard,
|
|
StygianBossTab,
|
|
type StygianBossDisplay,
|
|
} from "@/components/stygian/stygian-boss-display";
|
|
import {
|
|
AdminDropdown,
|
|
ConstellationSelect,
|
|
ElementSelect,
|
|
RefinementSelect,
|
|
} from "@/components/admin/ui/option-select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
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 { elementIconFilter } from "@/lib/catalog/element";
|
|
import { AutosaveCoordinator, type AutosaveStatus as CoordinatorStatus } from "@/lib/editor/autosave";
|
|
import { supportsWeaponType } from "@/lib/catalog/lunaris";
|
|
import { stygianBossPositionLabels, stygianBossPositions, stygianLevels } from "@/lib/stygian/constants";
|
|
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 = StygianBossDisplay & { level: number; position: number };
|
|
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[];
|
|
};
|
|
|
|
const characterPlaceholderIcon = <UserRoundIcon className="size-10" aria-hidden="true" />;
|
|
const weaponPlaceholderIcon = <Image src="/icon/nav/Weapon.png" alt="" width={32} height={32} className="size-8 object-contain opacity-60" />;
|
|
const artifactPlaceholderIcon = <Image src="/icon/nav/Artifact.webp" alt="" width={32} height={32} className="size-8 object-contain opacity-60" />;
|
|
|
|
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 ?? 0,
|
|
refinement: item.refinement ?? 1,
|
|
}
|
|
: { 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: 0,
|
|
refinement: 1,
|
|
});
|
|
else if (kind === "weapon") onAdd({ kind, catalogKey });
|
|
else onAdd({ kind: "artifact", catalogKey });
|
|
}}
|
|
placeholder={`Search ${kind}…`}
|
|
emptyLabel="No matches"
|
|
rarityBackground
|
|
characterBackground={kind === "character"}
|
|
/>
|
|
</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…"
|
|
placeholderIcon={characterPlaceholderIcon}
|
|
emptyLabel="No matches"
|
|
className="rounded-lg border-dashed"
|
|
onValueChange={(catalogKey) => {
|
|
if (catalogKey)
|
|
onAdd({
|
|
kind: "character",
|
|
catalogKey,
|
|
weaponKey: null,
|
|
artifactKey: null,
|
|
constellationLevel: 0,
|
|
refinement: 1,
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function VisualRecommendation({
|
|
item,
|
|
onChange,
|
|
onRemove,
|
|
characters,
|
|
weapons,
|
|
artifacts,
|
|
teamLayout = false,
|
|
dragHandle,
|
|
}: {
|
|
item: StygianRecommendationItemInput;
|
|
onChange: (item: StygianRecommendationItemInput) => void;
|
|
onRemove: () => void;
|
|
characters: Character[];
|
|
weapons: CatalogItem[];
|
|
artifacts: CatalogItem[];
|
|
teamLayout?: boolean;
|
|
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="relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
|
|
{dragHandle ? (
|
|
<div className="mb-1 flex h-7 items-center justify-end">
|
|
{dragHandle}
|
|
</div>
|
|
) : null}
|
|
{character ? (
|
|
<>
|
|
<div className={cn("grid gap-1", teamLayout && "grid-cols-3")}>
|
|
<CharacterSelect
|
|
presentation="slot"
|
|
items={characters}
|
|
value={character.key}
|
|
label="Choose character"
|
|
placeholder="Search character…"
|
|
placeholderIcon={characterPlaceholderIcon}
|
|
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: 0,
|
|
refinement: 1,
|
|
});
|
|
}}
|
|
/>
|
|
{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…"
|
|
placeholderIcon={weaponPlaceholderIcon}
|
|
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…"
|
|
placeholderIcon={artifactPlaceholderIcon}
|
|
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…"
|
|
placeholderIcon={artifactPlaceholderIcon}
|
|
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…"
|
|
placeholderIcon={weaponPlaceholderIcon}
|
|
emptyLabel="No matches"
|
|
onValueChange={(weaponKey) =>
|
|
updateCharacter({
|
|
weaponKey: weaponKey || null,
|
|
refinement: weaponKey
|
|
? item.kind === "character"
|
|
? (item.refinement ?? 1)
|
|
: 1
|
|
: null,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{teamLayout && item.kind === "character" ? (
|
|
<div className="mt-1 grid grid-cols-2 gap-1">
|
|
<ConstellationSelect
|
|
value={String(item.constellationLevel ?? 0)}
|
|
onValueChange={(next) =>
|
|
updateCharacter({
|
|
constellationLevel: Number(next),
|
|
})
|
|
}
|
|
triggerProps={{
|
|
size: "sm",
|
|
className: "h-7 min-w-0 justify-center px-1 text-xs",
|
|
}}
|
|
/>
|
|
<RefinementSelect
|
|
disabled={!weapon}
|
|
value={String(item.refinement ?? 1)}
|
|
onValueChange={(next) =>
|
|
updateCharacter({
|
|
refinement: 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…"
|
|
placeholderIcon={weaponPlaceholderIcon}
|
|
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…"
|
|
placeholderIcon={artifactPlaceholderIcon}
|
|
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"
|
|
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 recommendation"
|
|
{...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-4 gap-1 sm:grid-cols-[repeat(4,minmax(0,7.5rem))] sm:gap-2"
|
|
: "grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))]",
|
|
)}
|
|
>
|
|
{items.map((item, index) => {
|
|
const card = (dragHandle?: ReactNode) => (
|
|
<VisualRecommendation
|
|
key={sortableIds[index]}
|
|
item={item}
|
|
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 (
|
|
<SortableRecommendation key={sortableIds[index]} id={sortableIds[index]}>
|
|
{card}
|
|
</SortableRecommendation>
|
|
);
|
|
})}
|
|
{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 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, animateLayoutChanges: () => false });
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
|
className={cn("min-w-0", isDragging && "relative z-10 opacity-60")}
|
|
>
|
|
<div className="flex h-full flex-col gap-2 rounded-lg border bg-muted/20 p-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}
|
|
</div>
|
|
</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<{ id: string; items: StygianRecommendationItemInput[] }[]>(() =>
|
|
groups
|
|
.filter((group) => group.kind === "team")
|
|
.map((group) => ({ id: group.id, items: 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 = useMemo(() => teams.map((team) => team.id), [teams]);
|
|
const fingerprint = JSON.stringify({ teams: teams.map((team) => team.items), units });
|
|
const valid = teams.every(
|
|
(team) =>
|
|
team.items.length >= 1 &&
|
|
team.items.length <= 4 &&
|
|
team.items.every((item) => item.kind === "character"),
|
|
);
|
|
const [savedFingerprint, setSavedFingerprint] = useState(fingerprint);
|
|
const savedFingerprintRef = useRef(fingerprint);
|
|
const latestDraftRef = useRef({ fingerprint, valid });
|
|
const [saveStatus, setSaveStatus] = useState<CoordinatorStatus>({ state: "idle" });
|
|
const initialVersionRef = useRef(version);
|
|
const coordinatorRef = useRef<AutosaveCoordinator<
|
|
string,
|
|
{ fingerprint: string; expectedVersion: number; teams: StygianRecommendationItemInput[][]; units: StygianRecommendationItemInput[] },
|
|
string
|
|
> | null>(null);
|
|
const saveState = !valid
|
|
? "incomplete"
|
|
: saveStatus.state === "saving"
|
|
? "saving"
|
|
: fingerprint === savedFingerprint
|
|
? "saved"
|
|
: saveStatus.state === "error"
|
|
? "error"
|
|
: "unsaved";
|
|
|
|
useEffect(() => {
|
|
const coordinator = new AutosaveCoordinator({
|
|
initialVersion: initialVersionRef.current,
|
|
debounceMs: 2_500,
|
|
maxRetries: 0,
|
|
createPayload: (draft: string, expectedVersion) => ({
|
|
fingerprint: draft,
|
|
expectedVersion,
|
|
...JSON.parse(draft) as { teams: StygianRecommendationItemInput[][]; units: StygianRecommendationItemInput[] },
|
|
}),
|
|
save: async ({ fingerprint: draft, ...payload }) => {
|
|
const result = await saveStygianRecommendationsAction(scheduleId, {
|
|
...payload,
|
|
level,
|
|
bossPosition: boss.position,
|
|
});
|
|
return result.status === "saved"
|
|
? { status: "saved" as const, version: result.version, snapshot: draft }
|
|
: { status: "transient-error" as const, message: result.message };
|
|
},
|
|
onStatus: (status) => {
|
|
setSaveStatus(status);
|
|
if (status.state === "error") toast.error(status.message);
|
|
},
|
|
onSaved: (draft, savedVersion) => {
|
|
if (draft) {
|
|
savedFingerprintRef.current = draft;
|
|
setSavedFingerprint(draft);
|
|
const latest = latestDraftRef.current;
|
|
if (latest.valid && latest.fingerprint !== draft) coordinator.schedule(latest.fingerprint);
|
|
}
|
|
onVersionChange(savedVersion);
|
|
},
|
|
});
|
|
coordinatorRef.current = coordinator;
|
|
return () => {
|
|
coordinator.dispose();
|
|
coordinatorRef.current = null;
|
|
};
|
|
}, [boss.position, level, onVersionChange, scheduleId]);
|
|
|
|
useEffect(() => {
|
|
const coordinator = coordinatorRef.current;
|
|
if (!coordinator) return;
|
|
coordinator.updateVersion(version);
|
|
}, [version]);
|
|
|
|
useEffect(() => {
|
|
const coordinator = coordinatorRef.current;
|
|
if (!coordinator) return;
|
|
latestDraftRef.current = { fingerprint, valid };
|
|
if (!valid || fingerprint === savedFingerprintRef.current) coordinator.cancelPending();
|
|
else coordinator.schedule(fingerprint);
|
|
}, [fingerprint, valid]);
|
|
|
|
return (
|
|
<StygianBossCard boss={boss} level={level}>
|
|
<FieldSet className="gap-3">
|
|
<FieldLegend className="font-heading text-lg font-semibold">
|
|
Recommended team
|
|
</FieldLegend>
|
|
<DndContext
|
|
sensors={teamSensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={({ active, over }) => {
|
|
if (!over || active.id === over.id) return;
|
|
setTeams((current) => {
|
|
const from = current.findIndex((team) => team.id === active.id);
|
|
const to = current.findIndex((team) => team.id === over.id);
|
|
return from === -1 || to === -1 ? current : arrayMove(current, from, to);
|
|
});
|
|
}}
|
|
>
|
|
<SortableContext
|
|
items={teamIds}
|
|
strategy={rectSortingStrategy}
|
|
>
|
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
|
{teams.map((team, teamIndex) => (
|
|
<SortableTeam
|
|
key={team.id}
|
|
id={team.id}
|
|
position={teamIndex}
|
|
>
|
|
<VisualRecommendationGrid
|
|
items={team.items}
|
|
limit={4}
|
|
teamLayout
|
|
characters={characters}
|
|
weapons={weapons}
|
|
artifacts={artifacts}
|
|
onChange={(items) =>
|
|
setTeams((current) =>
|
|
current
|
|
.map((entry) => entry.id === team.id ? { ...entry, items } : entry)
|
|
.filter((entry) => entry.items.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((current) => current.filter((entry) => entry.id !== team.id))
|
|
}
|
|
>
|
|
<Trash2Icon data-icon="inline-start" />
|
|
Remove team
|
|
</DeleteConfirmationDialog>
|
|
</SortableTeam>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="self-start"
|
|
onClick={() => setTeams((current) => [...current, { id: crypto.randomUUID(), items: [] }])}
|
|
>
|
|
<PlusIcon data-icon="inline-start" />
|
|
Add team
|
|
</Button>
|
|
</FieldSet>
|
|
<FieldSet className="gap-3">
|
|
<FieldLegend className="font-heading text-lg font-semibold">
|
|
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={() => void coordinatorRef.current?.flush()}
|
|
/>
|
|
</div>
|
|
</StygianBossCard>
|
|
);
|
|
}
|
|
|
|
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 [version, setVersion] = useState(schedule.version);
|
|
const [level, setLevel] = useState<105 | 110>(105);
|
|
const [bossPosition, setBossPosition] = useState(0);
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<nav className="flex flex-wrap gap-2" aria-label="Stygian versions">
|
|
{schedules.map((entry) =>
|
|
entry.scheduleId === schedule.scheduleId ? (
|
|
<Button key={entry.scheduleId} disabled variant="secondary" size="sm">
|
|
{entry.challengeName}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
key={entry.scheduleId}
|
|
variant="outline"
|
|
size="sm"
|
|
nativeButton={false}
|
|
render={
|
|
<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />
|
|
}
|
|
>
|
|
{entry.challengeName}
|
|
</Button>
|
|
),
|
|
)}
|
|
<StygianSyncButton size="sm" />
|
|
</nav>
|
|
|
|
<Tabs
|
|
value={String(level)}
|
|
onValueChange={(next) => setLevel(Number(next) as 105 | 110)}
|
|
>
|
|
<TabsList>
|
|
{stygianLevels.map((entry) => (
|
|
<TabsTrigger key={entry} value={String(entry)}>Level {entry}</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
{stygianLevels.map((entry) => (
|
|
<TabsContent key={entry} value={String(entry)}>
|
|
<Tabs
|
|
value={String(bossPosition)}
|
|
onValueChange={(next) => setBossPosition(Number(next))}
|
|
>
|
|
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto group-data-horizontal/tabs:min-h-96" aria-label={`Level ${entry} bosses`}>
|
|
{stygianBossPositions.map((position) => {
|
|
const boss = bosses.find(
|
|
(candidate) =>
|
|
candidate.level === entry &&
|
|
candidate.position === position,
|
|
);
|
|
const label = boss?.name ?? `Boss ${position + 1}`;
|
|
const positionLabel = stygianBossPositionLabels[position];
|
|
return (
|
|
<TabsTrigger
|
|
key={position}
|
|
value={String(position)}
|
|
aria-label={`${positionLabel}: ${label}`}
|
|
title={label}
|
|
className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-2 py-3 data-active:bg-accent/50 data-active:ring-2 data-active:ring-primary data-active:ring-inset"
|
|
>
|
|
<StygianBossTab
|
|
boss={boss}
|
|
position={position}
|
|
priority={entry === 105}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|