157 lines
12 KiB
TypeScript
157 lines
12 KiB
TypeScript
import Image from "@/components/ui/resilient-image";
|
|
import Link from "next/link";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { buttonVariants } from "@/components/ui/button";
|
|
import { TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { StygianBossTabs, StygianNavigation } from "@/components/stygian/stygian-navigation";
|
|
import {
|
|
StygianBossCard,
|
|
StygianBossTab,
|
|
} from "@/components/stygian/stygian-boss-display";
|
|
import { elementIconFilter } from "@/lib/catalog/element";
|
|
import { characterRarityGradientClass, rarityGradientClass } from "@/lib/catalog/rarity";
|
|
import type { getCatalogOptions } from "@/lib/guides/queries";
|
|
import { stygianBossPositionLabels, stygianBossPositions, stygianLevels } from "@/lib/stygian/constants";
|
|
import type { getStygianSchedule } from "@/lib/stygian/repository";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Schedule = NonNullable<Awaited<ReturnType<typeof getStygianSchedule>>>;
|
|
type Catalog = NonNullable<Awaited<ReturnType<typeof getCatalogOptions>>>;
|
|
type Item = Schedule["recommendations"][number]["items"][number];
|
|
|
|
function recommendation(item: Item, catalog: Catalog) {
|
|
if (item.kind === "element" && item.element) {
|
|
const source = catalog.characters.find((character) => character.element === item.element && character.elementIconUrl);
|
|
return { name: item.element, imageUrl: source?.elementIconUrl ?? null, badge: "Element", rarity: null };
|
|
}
|
|
if (item.kind === "character") {
|
|
const character = catalog.characters.find((candidate) => candidate.key === item.catalogKey);
|
|
const weapon = catalog.weapons.find((candidate) => candidate.key === item.weaponKey);
|
|
const artifact = catalog.artifacts.find((candidate) => candidate.key === item.artifactKey);
|
|
return {
|
|
name: character?.name ?? item.catalogKey ?? "Unknown",
|
|
imageUrl: character?.imageUrl ?? null,
|
|
badge: item.constellationLevel === null ? "Character" : `C${item.constellationLevel}`,
|
|
rarity: character?.rarity ?? null,
|
|
weapon,
|
|
artifact,
|
|
refinement: item.refinement,
|
|
};
|
|
}
|
|
const collection = item.kind === "weapon" ? catalog.weapons : catalog.artifacts;
|
|
const value = collection.find((candidate) => candidate.key === item.catalogKey);
|
|
return { name: value?.name ?? item.catalogKey ?? "Unknown", imageUrl: value?.imageUrl ?? null, badge: item.kind, rarity: value?.rarity ?? null };
|
|
}
|
|
|
|
function RecommendationIcon({ item, catalog, teamLayout = false }: { item: Item; catalog: Catalog; teamLayout?: boolean }) {
|
|
const value = recommendation(item, catalog);
|
|
if (item.kind === "character") {
|
|
const hasEquipment = Boolean(value.weapon || value.artifact);
|
|
return (
|
|
<div className={cn("flex min-w-0 flex-col gap-1 rounded-lg border bg-card p-1", teamLayout && "w-full gap-2 sm:p-2")}>
|
|
<div className="flex flex-col gap-1">
|
|
<div className={cn("relative aspect-square overflow-hidden rounded-lg", characterRarityGradientClass(item.catalogKey, value.rarity))}>
|
|
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes={teamLayout ? "(min-width: 1280px) 128px, (min-width: 640px) 25vw, 50vw" : "128px"} className="object-contain" /> : null}
|
|
</div>
|
|
{hasEquipment ? <div className="grid grid-cols-2 gap-1">
|
|
{value.weapon ? <a href={`https://lunaris.moe/weapon/${encodeURIComponent(value.weapon.key)}`} target="_blank" rel="noreferrer" aria-label={`View ${value.weapon.name} on Lunaris`} title={value.weapon.name} className={cn("relative aspect-square overflow-hidden rounded-md border transition-opacity hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", rarityGradientClass(value.weapon.rarity))}><Image src={value.weapon.imageUrl} alt={value.weapon.name} fill sizes={teamLayout ? "(min-width: 1280px) 64px, (min-width: 640px) 12vw, 25vw" : "64px"} className="object-contain p-1" /></a> : null}
|
|
{value.artifact ? <a href={`https://lunaris.moe/artifact/${encodeURIComponent(value.artifact.key)}`} target="_blank" rel="noreferrer" aria-label={`View ${value.artifact.name} on Lunaris`} title={value.artifact.name} className={cn("relative aspect-square overflow-hidden rounded-md border transition-opacity hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", rarityGradientClass(value.artifact.rarity))}><Image src={value.artifact.imageUrl} alt={value.artifact.name} fill sizes={teamLayout ? "(min-width: 1280px) 64px, (min-width: 640px) 12vw, 25vw" : "64px"} className="object-contain p-1" /></a> : null}
|
|
</div> : null}
|
|
</div>
|
|
{teamLayout ? item.constellationLevel !== null || value.weapon && value.refinement ? <div className="flex justify-center gap-1">
|
|
{item.constellationLevel !== null ? <Badge variant="secondary">C{item.constellationLevel}</Badge> : null}
|
|
{value.weapon && value.refinement ? <Badge variant="secondary">R{value.refinement}</Badge> : null}
|
|
</div> : null : <span className="truncate text-center text-xs font-medium" title={value.name}>{value.name}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex min-w-0 flex-col items-center gap-1 text-center">
|
|
{item.kind === "weapon" || item.kind === "artifact" ? (
|
|
<a href={`https://lunaris.moe/${item.kind}/${encodeURIComponent(item.catalogKey ?? "")}`} target="_blank" rel="noreferrer" aria-label={`View ${value.name} on Lunaris`} title={value.name} className={cn("relative aspect-square w-full overflow-hidden rounded-lg border transition-opacity hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", rarityGradientClass(value.rarity))}>
|
|
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="100px" className="object-contain" /> : null}
|
|
</a>
|
|
) : (
|
|
<div className="relative aspect-square w-full overflow-hidden rounded-lg border">
|
|
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="100px" className="object-contain" style={item.element ? { filter: elementIconFilter(item.element) } : undefined} /> : null}
|
|
</div>
|
|
)}
|
|
<span className="max-w-[100px] truncate text-xs font-medium" title={value.name}>{value.name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BossCard({ schedule, level, position, catalog }: { schedule: Schedule; level: 105 | 110; position: number; catalog: Catalog }) {
|
|
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
|
|
if (!boss) return null;
|
|
const groups = schedule.recommendations.filter((group) => group.level === level && group.bossPosition === position);
|
|
const teams = groups.filter((group) => group.kind === "team" && group.items.length > 0);
|
|
const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items);
|
|
return (
|
|
<StygianBossCard boss={boss} level={level} mechanicsId={`stygian-mechanics-${level}-${position}`}>
|
|
{!teams.length && !units.length && <Alert>
|
|
<AlertTitle>คำแนะนำสำหรับบอสนี้กำลังจัดทำ</AlertTitle>
|
|
<AlertDescription>
|
|
<p>ยังไม่มีทีมและตัวละครแนะนำสำหรับระดับนี้ อ่านกลไกของบอสด้านล่าง หรือดูไกด์ตัวละครเพื่อเตรียมทีมระหว่างรอ</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<a href={`#stygian-mechanics-${level}-${position}`} className={buttonVariants({ variant: "outline", size: "sm" })}>อ่านกลไกบอส</a>
|
|
<Link href="/" className={buttonVariants({ variant: "outline", size: "sm" })}>เลือกไกด์ตัวละคร</Link>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>}
|
|
<section className="flex flex-col gap-3" aria-label={`ทีมแนะนำสำหรับ ${boss.name}`}>
|
|
<h3 className="font-heading text-lg font-semibold">ทีมแนะนำ</h3>
|
|
{teams.length ? <div className="grid grid-cols-1 items-start gap-3 xl:grid-cols-2">
|
|
{teams.map((team) => (
|
|
<div key={team.id} className="grid w-full min-w-0 grid-cols-2 items-start gap-2 rounded-lg border bg-muted/20 p-2 sm:grid-cols-4 sm:gap-3 sm:p-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" aria-hidden="true" />)}
|
|
</div>
|
|
))}
|
|
</div> : <p className="text-sm text-muted-foreground">ยังไม่มีทีมแนะนำสำหรับบอสนี้ในระดับที่เลือก</p>}
|
|
</section>
|
|
<section className="flex flex-col gap-3" aria-label={`ตัวเลือกแนะนำสำหรับ ${boss.name}`}>
|
|
<h3 className="font-heading text-lg font-semibold">ตัวเลือกแนะนำ</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">ยังไม่มีตัวเลือกแนะนำเพิ่มเติมในระดับที่เลือก</p>}
|
|
</section>
|
|
</StygianBossCard>
|
|
);
|
|
}
|
|
|
|
export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalog: Catalog }) {
|
|
return (
|
|
<StygianNavigation>
|
|
<div className="relative z-10 col-start-1 row-start-2 flex flex-col items-center gap-2">
|
|
<p className="text-sm font-medium">เลือกระดับบอส</p>
|
|
<TabsList aria-label="ระดับความยาก Stygian">
|
|
{stygianLevels.map((level) => (
|
|
<TabsTrigger key={level} value={String(level)} className="max-sm:min-h-11">เลเวล {level}</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</div>
|
|
{stygianLevels.map((level) => (
|
|
<TabsContent key={level} value={String(level)} className="col-start-1 row-span-3 row-start-1 grid grid-rows-subgrid">
|
|
<StygianBossTabs>
|
|
<div className="flex flex-col gap-2">
|
|
<p className="text-sm font-medium">เลือกบอส</p>
|
|
<TabsList variant="line" className="grid w-full grid-cols-3 overflow-hidden group-data-horizontal/tabs:h-auto" aria-label={`Level ${level} bosses`}>
|
|
{stygianBossPositions.map((position) => {
|
|
const boss = schedule.bosses.find((candidate) => candidate.level === level && 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-1 py-2 data-active:bg-accent/50 data-active:ring-2 data-active:ring-primary data-active:ring-inset sm:px-2 sm:py-3">
|
|
<StygianBossTab boss={boss} position={position} priority={level === 105} />
|
|
</TabsTrigger>;
|
|
})}
|
|
</TabsList>
|
|
</div>
|
|
{stygianBossPositions.map((position) => <TabsContent key={position} value={String(position)} className="row-start-3 pt-4"><BossCard schedule={schedule} level={level} position={position} catalog={catalog} /></TabsContent>)}
|
|
</StygianBossTabs>
|
|
</TabsContent>
|
|
))}
|
|
</StygianNavigation>
|
|
);
|
|
}
|