feat : update admin ui

This commit is contained in:
2026-10-01 00:53:20 +07:00 Unverified
parent 43ed3aebe9
commit cf23c572aa
4 changed files with 354 additions and 262 deletions
+2 -2
View File
@@ -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 }))}
+168 -193
View File
@@ -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">
{dragHandle ? (
<div className="mb-1 flex h-7 items-center justify-end">
{dragHandle}
</div>
) : null}
{character ? ( {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")}> <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,25 +753,23 @@ 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 type="button"
type="button" variant="ghost"
variant="ghost" size="icon-xs"
size="icon-xs" className="touch-none"
className="touch-none" aria-label={`Drag to reorder team ${position + 1}`}
aria-label={`Drag to reorder team ${position + 1}`} {...attributes}
{...attributes} {...listeners}
{...listeners} >
> <GripVerticalIcon />
<GripVerticalIcon /> </Button>
</Button> </div>
</div> {children}
{children} </div>
</CardContent>
</Card>
</div> </div>
); );
} }
@@ -900,50 +885,33 @@ function BossRecommendationEditor({
]); ]);
return ( return (
<Card> <StygianBossCard boss={boss} level={level}>
<CardContent className="grid gap-6 lg:grid-cols-[12rem_minmax(0,1fr)]"> <FieldSet className="gap-3">
<aside className="self-start"> <FieldLegend className="font-heading text-lg font-semibold">
<div className="relative aspect-square overflow-hidden rounded-xl bg-muted/30"> Recommended team
<Image </FieldLegend>
src={boss.imageUrl} <DndContext
alt={boss.name} sensors={teamSensors}
fill collisionDetection={closestCenter}
sizes="192px" onDragEnd={({ active, over }) => {
className="object-contain p-3" if (!over || active.id === over.id) return;
/> const from = teamIds.indexOf(String(active.id));
</div> const to = teamIds.indexOf(String(over.id));
<h3 className="mt-3 font-heading font-medium leading-snug"> if (from >= 0 && to >= 0)
{boss.name} setTeams((current) => arrayMove(current, from, to));
</h3> }}
<p className="mt-1 text-sm text-muted-foreground"> >
Level {level} recommendations <SortableContext
</p> items={teamIds}
</aside> strategy={verticalListSortingStrategy}
<div className="flex min-w-0 flex-col gap-6"> >
<FieldSet className="gap-3"> <div className="flex flex-col gap-3">
<FieldLegend>Recommended team</FieldLegend> {teams.map((team, teamIndex) => (
<DndContext <SortableTeam
sensors={teamSensors} key={teamIds[teamIndex]}
collisionDetection={closestCenter} id={teamIds[teamIndex]}
onDragEnd={({ active, over }) => { position={teamIndex}
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 <VisualRecommendationGrid
items={team} items={team}
limit={4} limit={4}
@@ -975,51 +943,53 @@ 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" />
Remove team Remove team
</DeleteConfirmationDialog> </DeleteConfirmationDialog>
</SortableTeam> </SortableTeam>
))} ))}
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="self-start" className="self-start"
onClick={() => setTeams([...teams, []])} onClick={() => setTeams([...teams, []])}
> >
<PlusIcon data-icon="inline-start" /> <PlusIcon data-icon="inline-start" />
Add team Add team
</Button> </Button>
</FieldSet> </FieldSet>
<FieldSet className="gap-3"> <FieldSet className="gap-3">
<FieldLegend>Recommended unit</FieldLegend> <FieldLegend className="font-heading text-lg font-semibold">
<VisualRecommendationGrid Recommended unit
items={units} </FieldLegend>
characters={characters} <VisualRecommendationGrid
weapons={weapons} items={units}
artifacts={artifacts} characters={characters}
onChange={setUnits} weapons={weapons}
/> artifacts={artifacts}
</FieldSet> onChange={setUnits}
<div className="flex justify-end"> />
<AutosaveStatus </FieldSet>
state={saveState} <div className="flex justify-end">
issues={ <AutosaveStatus
valid state={saveState}
? [] issues={
: ["Add at least 1 character to each team before autosave."] valid
} ? []
onSave={() => setFailedFingerprint(null)} : ["Add at least 1 character to each team before autosave."]
/> }
</div> onSave={() => setFailedFingerprint(null)}
</div> />
</CardContent> </div>
</Card> </StygianBossCard>
); );
} }
@@ -1047,53 +1017,50 @@ 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> {schedules.map((entry) =>
<CardTitle>Schedules</CardTitle> entry.scheduleId === schedule.scheduleId ? (
</CardHeader> <Button key={entry.scheduleId} disabled variant="secondary" size="sm">
<CardContent className="flex flex-wrap gap-2"> {entry.challengeName}
{schedules.map((entry) => </Button>
entry.scheduleId === schedule.scheduleId ? ( ) : (
<Button key={entry.scheduleId} disabled variant="secondary"> <Button
{entry.challengeName} key={entry.scheduleId}
</Button> variant="outline"
) : ( size="sm"
<Button nativeButton={false}
key={entry.scheduleId} render={
variant="outline" <Link href={`/admin/stygian?schedule=${entry.scheduleId}`} />
nativeButton={false} }
render={ >
<Link href={`/admin/stygian?schedule=${entry.scheduleId}`} /> {entry.challengeName}
} </Button>
> ),
{entry.challengeName} )}
</Button> <Button
), size="sm"
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" />
)} )}
<Button Sync archive
disabled={pending} </Button>
onClick={() => </nav>
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 <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)
+20 -67
View File
@@ -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,45 +79,21 @@ 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"> <section className="flex flex-col gap-3" aria-label={`Recommended teams for ${boss.name}`}>
<header className="text-center"> <h3 className="font-heading text-lg font-semibold">Recommended team</h3>
<h2 className="font-heading text-lg font-semibold">{boss.name}</h2> {teams.length ? teams.map((team) => (
<p className="mt-1 text-sm text-muted-foreground">Level {level} · HP {Math.round(boss.hp).toLocaleString("en-US")}</p> <div key={team.id} className="grid w-full grid-cols-4 items-start gap-1 rounded-lg border bg-muted/20 p-2 sm:flex sm:w-fit sm:flex-wrap sm:gap-3">
</header> {team.items.slice(0, 4).map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} teamLayout />)}
<div className="flex min-w-0 flex-col gap-5"> {Array.from({ length: Math.max(0, 4 - team.items.length) }, (_, index) => <div key={`empty-${index}`} className="aspect-square w-full rounded-lg border border-dashed bg-muted/30 sm:w-[116px]" aria-hidden="true" />)}
<Accordion> </div>
<AccordionItem value="overview"> )) : <p className="text-sm text-muted-foreground">No team recommendations yet.</p>}
<AccordionTrigger>Boss overview</AccordionTrigger> </section>
<AccordionContent className="whitespace-pre-line text-muted-foreground"> <section className="flex flex-col gap-3" aria-label={`Recommended units for ${boss.name}`}>
<LunarisText nodes={parseLunarisText(boss.description)} fallback={boss.description} /> <h3 className="font-heading text-lg font-semibold">Recommended unit</h3>
</AccordionContent> {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>}
</AccordionItem> </section>
{boss.mechanics.map((mechanic, index) => ( </StygianBossCard>
<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}`}>
<h3 className="font-heading text-lg font-semibold">Recommended team</h3>
{teams.length ? teams.map((team) => (
<div key={team.id} className="grid w-full grid-cols-4 items-start gap-1 rounded-lg border bg-muted/20 p-2 sm:flex sm:w-fit sm:flex-wrap sm:gap-3">
{team.items.slice(0, 4).map((item) => <RecommendationIcon key={`${item.groupId}-${item.position}`} item={item} catalog={catalog} teamLayout />)}
{Array.from({ length: Math.max(0, 4 - team.items.length) }, (_, index) => <div key={`empty-${index}`} className="aspect-square w-full rounded-lg border border-dashed bg-muted/30 sm:w-[116px]" aria-hidden="true" />)}
</div>
)) : <p className="text-sm text-muted-foreground">No team recommendations yet.</p>}
</section>
<section className="flex flex-col gap-3" aria-label={`Recommended units for ${boss.name}`}>
<h3 className="font-heading text-lg font-semibold">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>}
</section>
</div>
</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>
+164
View File
@@ -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>
);
}