feat : update admin ui
This commit is contained in:
@@ -19,13 +19,13 @@ export default async function AdminStygianPage({ searchParams }: { searchParams:
|
|||||||
const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getActiveCatalog()]);
|
const [schedule, catalog] = await Promise.all([selectedId ? getStygianSchedule(selectedId) : null, getActiveCatalog()]);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
<div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-7xl flex-col gap-8 p-4 sm:p-6 lg:p-8">
|
||||||
<header><p className="text-sm font-medium text-primary">Stygian Onslaught</p><h1 className="font-heading text-3xl font-semibold">Stygian Guide</h1><p className="mt-2 text-muted-foreground">Sync Lunaris schedules and prepare recommendations.</p></header>
|
<header><p className="text-sm font-medium text-primary">Stygian Onslaught</p><h1 className="font-heading text-4xl font-semibold">Stygian Guide</h1></header>
|
||||||
{!schedule || !catalog ? <Alert><AlertTitle>No Stygian schedules yet</AlertTitle><AlertDescription>Use the sync action below after the catalog has been synchronized.</AlertDescription></Alert> : null}
|
{!schedule || !catalog ? <Alert><AlertTitle>No Stygian schedules yet</AlertTitle><AlertDescription>Use the sync action below after the catalog has been synchronized.</AlertDescription></Alert> : null}
|
||||||
{schedule && catalog ? <StygianEditor
|
{schedule && catalog ? <StygianEditor
|
||||||
key={schedule.scheduleId}
|
key={schedule.scheduleId}
|
||||||
schedules={schedules.map(({ scheduleId, challengeName }) => ({ scheduleId, challengeName }))}
|
schedules={schedules.map(({ scheduleId, challengeName }) => ({ scheduleId, challengeName }))}
|
||||||
schedule={{ scheduleId: schedule.scheduleId, challengeName: schedule.challengeName, version: schedule.version }}
|
schedule={{ scheduleId: schedule.scheduleId, challengeName: schedule.challengeName, version: schedule.version }}
|
||||||
bosses={schedule.bosses.map(({ level, position, name, imageUrl }) => ({ level, position, name, imageUrl }))}
|
bosses={schedule.bosses}
|
||||||
recommendations={schedule.recommendations}
|
recommendations={schedule.recommendations}
|
||||||
characters={catalog.characters.map(({ key, name, aliases, imageUrl, rarity, weaponType, element, elementIconUrl }) => ({ key, name, aliases, imageUrl, rarity, weaponType, element, elementIconUrl }))}
|
characters={catalog.characters.map(({ key, name, aliases, imageUrl, rarity, weaponType, element, elementIconUrl }) => ({ key, name, aliases, imageUrl, rarity, weaponType, element, elementIconUrl }))}
|
||||||
weapons={catalog.weapons.map(({ key, name, aliases, imageUrl, rarity, weaponType }) => ({ key, name, aliases, imageUrl, rarity, weaponType }))}
|
weapons={catalog.weapons.map(({ key, name, aliases, imageUrl, rarity, weaponType }) => ({ key, name, aliases, imageUrl, rarity, weaponType }))}
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ import {
|
|||||||
WeaponSelect,
|
WeaponSelect,
|
||||||
} from "@/components/admin/ui/catalog-select";
|
} from "@/components/admin/ui/catalog-select";
|
||||||
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
import { DeleteConfirmationDialog } from "@/components/admin/ui/delete-confirmation-dialog";
|
||||||
|
import {
|
||||||
|
StygianBossCard,
|
||||||
|
StygianBossTab,
|
||||||
|
type StygianBossDisplay,
|
||||||
|
} from "@/components/stygian/stygian-boss-display";
|
||||||
import {
|
import {
|
||||||
AdminDropdown,
|
AdminDropdown,
|
||||||
ConstellationSelect,
|
ConstellationSelect,
|
||||||
@@ -50,12 +55,6 @@ import {
|
|||||||
} from "@/components/admin/ui/option-select";
|
} from "@/components/admin/ui/option-select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
@@ -94,7 +93,7 @@ type Character = CatalogItem & {
|
|||||||
elementIconUrl: string | null;
|
elementIconUrl: string | null;
|
||||||
};
|
};
|
||||||
type ScheduleSummary = { scheduleId: number; challengeName: string };
|
type ScheduleSummary = { scheduleId: number; challengeName: string };
|
||||||
type Boss = { level: number; position: number; name: string; imageUrl: string };
|
type Boss = StygianBossDisplay & { level: number; position: number };
|
||||||
type StoredItem = {
|
type StoredItem = {
|
||||||
kind: string;
|
kind: string;
|
||||||
catalogKey: string | null;
|
catalogKey: string | null;
|
||||||
@@ -315,7 +314,6 @@ function VisualRecommendation({
|
|||||||
weapons,
|
weapons,
|
||||||
artifacts,
|
artifacts,
|
||||||
teamLayout = false,
|
teamLayout = false,
|
||||||
position,
|
|
||||||
dragHandle,
|
dragHandle,
|
||||||
}: {
|
}: {
|
||||||
item: StygianRecommendationItemInput;
|
item: StygianRecommendationItemInput;
|
||||||
@@ -325,7 +323,6 @@ function VisualRecommendation({
|
|||||||
weapons: CatalogItem[];
|
weapons: CatalogItem[];
|
||||||
artifacts: CatalogItem[];
|
artifacts: CatalogItem[];
|
||||||
teamLayout?: boolean;
|
teamLayout?: boolean;
|
||||||
position?: number;
|
|
||||||
dragHandle?: ReactNode;
|
dragHandle?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const character =
|
const character =
|
||||||
@@ -364,17 +361,14 @@ function VisualRecommendation({
|
|||||||
if (item.kind === "character") onChange({ ...item, ...patch });
|
if (item.kind === "character") onChange({ ...item, ...patch });
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="group relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
|
<div className="relative min-w-0 overflow-hidden rounded-xl border bg-card p-1.5">
|
||||||
{character ? (
|
{dragHandle ? (
|
||||||
<>
|
<div className="mb-1 flex h-7 items-center justify-end">
|
||||||
{teamLayout ? (
|
|
||||||
<div className="mb-1 flex h-7 items-center justify-between">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
#{(position ?? 0) + 1}
|
|
||||||
</span>
|
|
||||||
{dragHandle}
|
{dragHandle}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{character ? (
|
||||||
|
<>
|
||||||
<div className={cn("grid gap-1", teamLayout && "grid-cols-3")}>
|
<div className={cn("grid gap-1", teamLayout && "grid-cols-3")}>
|
||||||
<CharacterSelect
|
<CharacterSelect
|
||||||
presentation="slot"
|
presentation="slot"
|
||||||
@@ -582,7 +576,7 @@ function VisualRecommendation({
|
|||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
className="absolute top-2 left-2"
|
||||||
aria-label="Remove recommendation"
|
aria-label="Remove recommendation"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
@@ -619,7 +613,7 @@ function SortableRecommendation({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-xs"
|
size="icon-xs"
|
||||||
className="touch-none"
|
className="touch-none"
|
||||||
aria-label="Drag to reorder character"
|
aria-label="Drag to reorder recommendation"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
@@ -666,7 +660,7 @@ function VisualRecommendationGrid({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-2",
|
"grid gap-2",
|
||||||
teamLayout
|
teamLayout
|
||||||
? "grid-cols-[repeat(4,minmax(0,7.5rem))]"
|
? "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))]",
|
: "grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -675,7 +669,6 @@ function VisualRecommendationGrid({
|
|||||||
<VisualRecommendation
|
<VisualRecommendation
|
||||||
key={sortableIds[index]}
|
key={sortableIds[index]}
|
||||||
item={item}
|
item={item}
|
||||||
position={index}
|
|
||||||
dragHandle={dragHandle}
|
dragHandle={dragHandle}
|
||||||
teamLayout={teamLayout || characterOnly}
|
teamLayout={teamLayout || characterOnly}
|
||||||
characters={characters}
|
characters={characters}
|
||||||
@@ -693,15 +686,10 @@ function VisualRecommendationGrid({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return teamLayout ? (
|
return (
|
||||||
<SortableRecommendation
|
<SortableRecommendation key={sortableIds[index]} id={sortableIds[index]}>
|
||||||
key={sortableIds[index]}
|
|
||||||
id={sortableIds[index]}
|
|
||||||
>
|
|
||||||
{card}
|
{card}
|
||||||
</SortableRecommendation>
|
</SortableRecommendation>
|
||||||
) : (
|
|
||||||
card()
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{Array.from({ length: emptyCount }, (_, index) =>
|
{Array.from({ length: emptyCount }, (_, index) =>
|
||||||
@@ -723,7 +711,6 @@ function VisualRecommendationGrid({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!teamLayout) return cards;
|
|
||||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
const from = sortableIds.indexOf(String(active.id));
|
const from = sortableIds.indexOf(String(active.id));
|
||||||
@@ -766,8 +753,7 @@ function SortableTeam({
|
|||||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||||
className={cn(isDragging && "relative z-10 opacity-60")}
|
className={cn(isDragging && "relative z-10 opacity-60")}
|
||||||
>
|
>
|
||||||
<Card size="sm">
|
<div className="flex flex-col gap-2 rounded-lg border bg-muted/20 p-2">
|
||||||
<CardContent className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-medium">Team {position + 1}</span>
|
<span className="text-sm font-medium">Team {position + 1}</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -783,8 +769,7 @@ function SortableTeam({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -900,28 +885,11 @@ function BossRecommendationEditor({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<StygianBossCard boss={boss} level={level}>
|
||||||
<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">
|
<FieldSet className="gap-3">
|
||||||
<FieldLegend>Recommended team</FieldLegend>
|
<FieldLegend className="font-heading text-lg font-semibold">
|
||||||
|
Recommended team
|
||||||
|
</FieldLegend>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={teamSensors}
|
sensors={teamSensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -975,7 +943,9 @@ function BossRecommendationEditor({
|
|||||||
description="This team and all of its character recommendations will be removed."
|
description="This team and all of its character recommendations will be removed."
|
||||||
confirmLabel="Remove team"
|
confirmLabel="Remove team"
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
setTeams(teams.filter((_, index) => index !== teamIndex))
|
setTeams(
|
||||||
|
teams.filter((_, index) => index !== teamIndex),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Trash2Icon data-icon="inline-start" />
|
<Trash2Icon data-icon="inline-start" />
|
||||||
@@ -997,7 +967,9 @@ function BossRecommendationEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
<FieldSet className="gap-3">
|
<FieldSet className="gap-3">
|
||||||
<FieldLegend>Recommended unit</FieldLegend>
|
<FieldLegend className="font-heading text-lg font-semibold">
|
||||||
|
Recommended unit
|
||||||
|
</FieldLegend>
|
||||||
<VisualRecommendationGrid
|
<VisualRecommendationGrid
|
||||||
items={units}
|
items={units}
|
||||||
characters={characters}
|
characters={characters}
|
||||||
@@ -1017,9 +989,7 @@ function BossRecommendationEditor({
|
|||||||
onSave={() => setFailedFingerprint(null)}
|
onSave={() => setFailedFingerprint(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</StygianBossCard>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,20 +1017,17 @@ export function StygianEditor({
|
|||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Card>
|
<nav className="flex flex-wrap gap-2" aria-label="Stygian versions">
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Schedules</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
{schedules.map((entry) =>
|
{schedules.map((entry) =>
|
||||||
entry.scheduleId === schedule.scheduleId ? (
|
entry.scheduleId === schedule.scheduleId ? (
|
||||||
<Button key={entry.scheduleId} disabled variant="secondary">
|
<Button key={entry.scheduleId} disabled variant="secondary" size="sm">
|
||||||
{entry.challengeName}
|
{entry.challengeName}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
key={entry.scheduleId}
|
key={entry.scheduleId}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
render={
|
render={
|
||||||
<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />
|
<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />
|
||||||
@@ -1071,6 +1038,7 @@ export function StygianEditor({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
size="sm"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -1092,8 +1060,7 @@ export function StygianEditor({
|
|||||||
)}
|
)}
|
||||||
Sync archive
|
Sync archive
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</nav>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={String(level)}
|
value={String(level)}
|
||||||
@@ -1109,23 +1076,31 @@ export function StygianEditor({
|
|||||||
value={String(bossPosition)}
|
value={String(bossPosition)}
|
||||||
onValueChange={(next) => setBossPosition(Number(next))}
|
onValueChange={(next) => setBossPosition(Number(next))}
|
||||||
>
|
>
|
||||||
<TabsList
|
<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`}>
|
||||||
variant="line"
|
{[0, 1, 2].map((position) => {
|
||||||
className="grid h-10 w-full grid-cols-3 overflow-hidden"
|
const boss = bosses.find(
|
||||||
>
|
(candidate) =>
|
||||||
{bosses
|
candidate.level === entry &&
|
||||||
.filter((boss) => boss.level === entry)
|
candidate.position === position,
|
||||||
.map((boss) => (
|
);
|
||||||
|
const label = boss?.name ?? `Boss ${position + 1}`;
|
||||||
|
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
|
||||||
|
return (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={boss.position}
|
key={position}
|
||||||
value={String(boss.position)}
|
value={String(position)}
|
||||||
className="h-full min-w-0 overflow-hidden rounded-none px-2"
|
aria-label={`${positionLabel}: ${label}`}
|
||||||
|
title={label}
|
||||||
|
className="h-full min-w-0 flex-col justify-start overflow-hidden rounded-none px-2 py-3"
|
||||||
>
|
>
|
||||||
<span className="block min-w-0 truncate">
|
<StygianBossTab
|
||||||
{boss.name}
|
boss={boss}
|
||||||
</span>
|
position={position}
|
||||||
|
priority={entry === 110}
|
||||||
|
/>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{bosses
|
{bosses
|
||||||
.filter((boss) => boss.level === entry)
|
.filter((boss) => boss.level === entry)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { CheckIcon, XIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { LunarisText } from "@/components/public/lunaris-text";
|
import {
|
||||||
import { parseLunarisText } from "@/lib/catalog/lunaris";
|
StygianBossCard,
|
||||||
|
StygianBossTab,
|
||||||
|
} from "@/components/stygian/stygian-boss-display";
|
||||||
import { elementIconFilter } from "@/lib/catalog/element";
|
import { elementIconFilter } from "@/lib/catalog/element";
|
||||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||||
import type { getActiveCatalog } from "@/lib/guides/queries";
|
import type { getActiveCatalog } from "@/lib/guides/queries";
|
||||||
@@ -17,24 +16,6 @@ type Schedule = NonNullable<Awaited<ReturnType<typeof getStygianSchedule>>>;
|
|||||||
type Catalog = NonNullable<Awaited<ReturnType<typeof getActiveCatalog>>>;
|
type Catalog = NonNullable<Awaited<ReturnType<typeof getActiveCatalog>>>;
|
||||||
type Item = Schedule["recommendations"][number]["items"][number];
|
type Item = Schedule["recommendations"][number]["items"][number];
|
||||||
|
|
||||||
function RecommendedMechanic({ mechanic, type }: { mechanic: string; type: string }) {
|
|
||||||
const parts = mechanic.split(/(\{SPRITE_PRESET#\d+\})/gu);
|
|
||||||
return (
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-1">
|
|
||||||
{parts.map((part, index) => {
|
|
||||||
const preset = /^\{SPRITE_PRESET#(\d+)\}$/u.exec(part)?.[1];
|
|
||||||
if (preset) return <Image key={`${part}-${index}`} src={`https://api.lunaris.moe/data/assets/icons/${preset}.png`} alt="" width={20} height={20} className="size-5 shrink-0 object-contain" />;
|
|
||||||
return part ? <LunarisText key={`${part}-${index}`} nodes={parseLunarisText(part)} fallback={part} /> : null;
|
|
||||||
})}
|
|
||||||
{type === "Disadvantage"
|
|
||||||
? <XIcon className="size-4 text-red-500" aria-hidden="true" />
|
|
||||||
: type === "Advantage"
|
|
||||||
? <CheckIcon className="size-4 text-emerald-500" aria-hidden="true" />
|
|
||||||
: null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function recommendation(item: Item, catalog: Catalog) {
|
function recommendation(item: Item, catalog: Catalog) {
|
||||||
if (item.kind === "element" && item.element) {
|
if (item.kind === "element" && item.element) {
|
||||||
const source = catalog.characters.find((character) => character.element === item.element && character.elementIconUrl);
|
const source = catalog.characters.find((character) => character.element === item.element && character.elementIconUrl);
|
||||||
@@ -98,29 +79,7 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule;
|
|||||||
const teams = groups.filter((group) => group.kind === "team");
|
const teams = groups.filter((group) => group.kind === "team");
|
||||||
const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items);
|
const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items);
|
||||||
return (
|
return (
|
||||||
<Card>
|
<StygianBossCard boss={boss} level={level}>
|
||||||
<CardContent className="flex flex-col gap-6">
|
|
||||||
<header className="text-center">
|
|
||||||
<h2 className="font-heading text-lg font-semibold">{boss.name}</h2>
|
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Level {level} · HP {Math.round(boss.hp).toLocaleString("en-US")}</p>
|
|
||||||
</header>
|
|
||||||
<div className="flex min-w-0 flex-col gap-5">
|
|
||||||
<Accordion>
|
|
||||||
<AccordionItem value="overview">
|
|
||||||
<AccordionTrigger>Boss overview</AccordionTrigger>
|
|
||||||
<AccordionContent className="whitespace-pre-line text-muted-foreground">
|
|
||||||
<LunarisText nodes={parseLunarisText(boss.description)} fallback={boss.description} />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
{boss.mechanics.map((mechanic, index) => (
|
|
||||||
<AccordionItem key={`${mechanic.name}-${index}`} value={`mechanic-${index}`}>
|
|
||||||
<AccordionTrigger>{mechanic.name}</AccordionTrigger>
|
|
||||||
<AccordionContent className="text-muted-foreground">
|
|
||||||
<LunarisText nodes={parseLunarisText(mechanic.description)} fallback={mechanic.description} />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
<section className="flex flex-col gap-3" aria-label={`Recommended teams for ${boss.name}`}>
|
<section className="flex flex-col gap-3" aria-label={`Recommended teams for ${boss.name}`}>
|
||||||
<h3 className="font-heading text-lg font-semibold">Recommended team</h3>
|
<h3 className="font-heading text-lg font-semibold">Recommended team</h3>
|
||||||
{teams.length ? teams.map((team) => (
|
{teams.length ? teams.map((team) => (
|
||||||
@@ -134,9 +93,7 @@ function BossCard({ schedule, level, position, catalog }: { schedule: Schedule;
|
|||||||
<h3 className="font-heading text-lg font-semibold">Recommended unit</h3>
|
<h3 className="font-heading text-lg font-semibold">Recommended unit</h3>
|
||||||
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(5rem,6.25rem))] 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>}
|
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(5rem,6.25rem))] 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>
|
</section>
|
||||||
</div>
|
</StygianBossCard>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +113,7 @@ export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalo
|
|||||||
const label = boss?.name ?? `Boss ${position + 1}`;
|
const label = boss?.name ?? `Boss ${position + 1}`;
|
||||||
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
|
const positionLabel = ["บน", "กลาง", "ล่าง"][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">
|
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">
|
||||||
{boss ? <Image src={boss.imageUrl} alt="" width={256} height={256} sizes="256px" className="size-64 shrink-0 object-contain" priority={level === 110} /> : <span>{position + 1}</span>}
|
<StygianBossTab boss={boss} position={position} priority={level === 110} />
|
||||||
<span className="text-base font-semibold">{positionLabel}</span>
|
|
||||||
{boss?.recommendedMechanics.some(Boolean) ? <span className="mt-2 flex flex-col items-center gap-1 text-xs font-normal whitespace-normal text-muted-foreground">
|
|
||||||
{boss.recommendedMechanics.map((mechanic, index) => mechanic ? <RecommendedMechanic key={`${mechanic}-${index}`} mechanic={mechanic} type={boss.recommendedMechanicTypes[index] ?? (index === 0 ? "Advantage" : index === 1 ? "Disadvantage" : "None")} /> : null)}
|
|
||||||
</span> : null}
|
|
||||||
</TabsTrigger>;
|
</TabsTrigger>;
|
||||||
})}
|
})}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { CheckIcon, XIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { LunarisText } from "@/components/public/lunaris-text";
|
||||||
|
import { parseLunarisText } from "@/lib/catalog/lunaris";
|
||||||
|
|
||||||
|
export type StygianBossDisplay = {
|
||||||
|
name: string;
|
||||||
|
imageUrl: string;
|
||||||
|
hp: number;
|
||||||
|
description: string;
|
||||||
|
mechanics: { name: string; description: string }[];
|
||||||
|
recommendedMechanics: string[];
|
||||||
|
recommendedMechanicTypes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function RecommendedMechanic({
|
||||||
|
mechanic,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
mechanic: string;
|
||||||
|
type: string;
|
||||||
|
}) {
|
||||||
|
const parts = mechanic.split(/(\{SPRITE_PRESET#\d+\})/gu);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-1">
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const preset = /^\{SPRITE_PRESET#(\d+)\}$/u.exec(part)?.[1];
|
||||||
|
if (preset)
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
key={`${part}-${index}`}
|
||||||
|
src={`https://api.lunaris.moe/data/assets/icons/${preset}.png`}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
className="size-5 shrink-0 object-contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return part ? (
|
||||||
|
<LunarisText
|
||||||
|
key={`${part}-${index}`}
|
||||||
|
nodes={parseLunarisText(part)}
|
||||||
|
fallback={part}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
})}
|
||||||
|
{type === "Disadvantage" ? (
|
||||||
|
<XIcon className="size-4 text-red-500" aria-hidden="true" />
|
||||||
|
) : type === "Advantage" ? (
|
||||||
|
<CheckIcon className="size-4 text-emerald-500" aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StygianBossTab({
|
||||||
|
boss,
|
||||||
|
position,
|
||||||
|
priority = false,
|
||||||
|
}: {
|
||||||
|
boss: StygianBossDisplay | undefined;
|
||||||
|
position: number;
|
||||||
|
priority?: boolean;
|
||||||
|
}) {
|
||||||
|
const positionLabel = ["บน", "กลาง", "ล่าง"][position];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{boss ? (
|
||||||
|
<Image
|
||||||
|
src={boss.imageUrl}
|
||||||
|
alt=""
|
||||||
|
width={256}
|
||||||
|
height={256}
|
||||||
|
sizes="256px"
|
||||||
|
className="size-64 shrink-0 object-contain"
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{position + 1}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-base font-semibold">{positionLabel}</span>
|
||||||
|
{boss?.recommendedMechanics.some(Boolean) ? (
|
||||||
|
<span className="mt-2 flex flex-col items-center gap-1 text-xs font-normal whitespace-normal text-muted-foreground">
|
||||||
|
{boss.recommendedMechanics.map((mechanic, index) =>
|
||||||
|
mechanic ? (
|
||||||
|
<RecommendedMechanic
|
||||||
|
key={`${mechanic}-${index}`}
|
||||||
|
mechanic={mechanic}
|
||||||
|
type={
|
||||||
|
boss.recommendedMechanicTypes[index] ??
|
||||||
|
(index === 0
|
||||||
|
? "Advantage"
|
||||||
|
: index === 1
|
||||||
|
? "Disadvantage"
|
||||||
|
: "None")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StygianBossCard({
|
||||||
|
boss,
|
||||||
|
level,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
boss: StygianBossDisplay;
|
||||||
|
level: 105 | 110;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
<header className="text-center">
|
||||||
|
<h2 className="font-heading text-lg font-semibold">{boss.name}</h2>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Level {level} · HP {Math.round(boss.hp).toLocaleString("en-US")}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<div className="flex min-w-0 flex-col gap-5">
|
||||||
|
<Accordion>
|
||||||
|
<AccordionItem value="overview">
|
||||||
|
<AccordionTrigger>Boss overview</AccordionTrigger>
|
||||||
|
<AccordionContent className="whitespace-pre-line text-muted-foreground">
|
||||||
|
<LunarisText
|
||||||
|
nodes={parseLunarisText(boss.description)}
|
||||||
|
fallback={boss.description}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
{boss.mechanics.map((mechanic, index) => (
|
||||||
|
<AccordionItem
|
||||||
|
key={`${mechanic.name}-${index}`}
|
||||||
|
value={`mechanic-${index}`}
|
||||||
|
>
|
||||||
|
<AccordionTrigger>{mechanic.name}</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-muted-foreground">
|
||||||
|
<LunarisText
|
||||||
|
nodes={parseLunarisText(mechanic.description)}
|
||||||
|
fallback={mechanic.description}
|
||||||
|
/>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user