feat : add stygian guide
This commit is contained in:
@@ -33,6 +33,14 @@ export function SiteHeader({ editHref = "/admin", guideId }: { editHref?: string
|
||||
<span className="hidden sm:inline">ไกด์เกนชินไม่ใช่เกมมือถือ</span>
|
||||
</Link>
|
||||
<nav aria-label="Public navigation" className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href="/stygian" />}
|
||||
>
|
||||
Stygian Guide
|
||||
</Button>
|
||||
<VisitorPresence guideId={guideId} />
|
||||
<Suspense fallback={null}>
|
||||
<AdminEditLink editHref={editHref} />
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { LunarisText } from "@/components/public/lunaris-text";
|
||||
import { parseLunarisText } from "@/lib/catalog/lunaris";
|
||||
import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import type { getActiveCatalog } from "@/lib/guides/queries";
|
||||
import type { getActiveStygianSchedule } from "@/lib/stygian/repository";
|
||||
|
||||
type Schedule = NonNullable<Awaited<ReturnType<typeof getActiveStygianSchedule>>>;
|
||||
type Catalog = NonNullable<Awaited<ReturnType<typeof getActiveCatalog>>>;
|
||||
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" };
|
||||
}
|
||||
if (item.kind === "constellation" && item.characterKey && item.constellationLevel) {
|
||||
const character = catalog.characters.find((candidate) => candidate.key === item.characterKey);
|
||||
const constellation = character?.constellations.find((candidate) => candidate.level === item.constellationLevel);
|
||||
return { name: `${character?.name ?? item.characterKey} C${item.constellationLevel}`, imageUrl: constellation?.iconUrl ?? character?.imageUrl ?? null, badge: "Constellation" };
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
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") {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1 rounded-lg border bg-card p-1">
|
||||
<div className={teamLayout ? "grid grid-cols-3 gap-1" : "flex flex-col gap-1"}>
|
||||
<div className={`${teamLayout ? "col-span-2 row-span-2" : ""} relative aspect-square overflow-hidden rounded-lg ${rarityGradientClass(value.rarity)}`}>
|
||||
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="128px" className="object-contain" /> : null}
|
||||
</div>
|
||||
<div className={teamLayout ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-1"}>
|
||||
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
|
||||
{value.weapon ? <Image src={value.weapon.imageUrl} alt={value.weapon.name} fill sizes="64px" className="object-contain p-1" /> : null}
|
||||
</div>
|
||||
<div className="relative aspect-square overflow-hidden rounded-md border bg-muted">
|
||||
{value.artifact ? <Image src={value.artifact.imageUrl} alt={value.artifact.name} fill sizes="64px" className="object-contain p-1" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-1"><span className="truncate text-xs font-medium" title={value.name}>{value.name}</span><Badge variant="secondary">{value.badge}</Badge></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-center gap-1 text-center">
|
||||
<div className="relative size-16 overflow-hidden rounded-lg border bg-muted">
|
||||
{value.imageUrl ? <Image src={value.imageUrl} alt={value.name} fill sizes="64px" className="object-contain" /> : null}
|
||||
</div>
|
||||
<span className="max-w-20 truncate text-xs font-medium" title={value.name}>{value.name}</span>
|
||||
<Badge variant="outline" className="capitalize">{value.badge}</Badge>
|
||||
</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");
|
||||
const units = groups.filter((group) => group.kind === "unit").flatMap((group) => group.items);
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="grid gap-6 md:grid-cols-[14rem_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="224px" className="object-contain p-3" priority={level === 110} />
|
||||
</div>
|
||||
<h2 className="mt-3 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>
|
||||
</aside>
|
||||
<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}`}>
|
||||
<h3 className="font-heading text-lg font-semibold">REC TEAM</h3>
|
||||
{teams.length ? teams.map((team) => (
|
||||
<div key={team.id} className="grid grid-cols-[repeat(4,minmax(0,7.5rem))] gap-2 rounded-lg border bg-muted/20 p-2">
|
||||
{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-[3/4] rounded-lg border border-dashed bg-muted/30" 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">REC UNIT</h3>
|
||||
{units.length ? <div className="grid grid-cols-[repeat(auto-fill,minmax(6rem,7.5rem))] 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function StygianGuide({ schedule, catalog }: { schedule: Schedule; catalog: Catalog }) {
|
||||
return (
|
||||
<Tabs defaultValue="110">
|
||||
<TabsList aria-label="Stygian difficulty">
|
||||
<TabsTrigger value="105">Level 105</TabsTrigger>
|
||||
<TabsTrigger value="110">Level 110</TabsTrigger>
|
||||
</TabsList>
|
||||
{([105, 110] as const).map((level) => (
|
||||
<TabsContent key={level} value={String(level)}>
|
||||
<Tabs defaultValue="0">
|
||||
<TabsList className="grid h-auto w-full grid-cols-3" aria-label={`Level ${level} bosses`}>
|
||||
{[0, 1, 2].map((position) => {
|
||||
const boss = schedule.bosses.find((candidate) => candidate.level === level && candidate.position === position);
|
||||
return <TabsTrigger key={position} value={String(position)} className="min-w-0 py-2"><span className="truncate">{boss?.name ?? `Boss ${position + 1}`}</span></TabsTrigger>;
|
||||
})}
|
||||
</TabsList>
|
||||
{[0, 1, 2].map((position) => <TabsContent key={position} value={String(position)} className="pt-4"><BossCard schedule={schedule} level={level} position={position} catalog={catalog} /></TabsContent>)}
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user