Files
buzz-sheet/components/public/structured-guide.tsx
T
gunshiz dc5c667b4e
CI / Verify (push) Successful in 1m0s
CI / Build immutable images and deploy (push) Failing after 2m38s
feat(guides): add structured image-first editor
2026-08-29 13:30:52 +00:00

56 lines
12 KiB
TypeScript

import Image from "next/image";
import Link from "next/link";
import { ChevronLeftIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { SiteHeader } from "@/components/public/site-header";
import { LiveRefresh } from "@/components/live-refresh";
import type { PublicGuide } from "@/lib/guides/queries";
import { cn } from "@/lib/utils";
function hasSection(guide: PublicGuide, slug: string): boolean {
if (slug === "weapon") return guide.weapons.length > 0;
if (slug === "artifact") return guide.artifactGroups.some((group) => group.rows.length > 0) || Boolean(guide.artifactProfile && (guide.artifactProfile.sands.length || guide.artifactProfile.goblet.length || guide.artifactProfile.circlet.length));
if (slug === "constellations") return guide.constellations.length > 0;
if (slug === "team") return guide.teams.length > 0;
return guide.extras.some((extra) => extra.slug === slug && extra.rows.length > 0);
}
export function publicSections(guide: PublicGuide) {
return guide.sections.filter((section) => hasSection(guide, section.slug));
}
export function StructuredGuideShell({ guide, section }: { guide: PublicGuide; section?: string }) {
const sections = publicSections(guide);
return <div className="min-h-svh bg-background"><LiveRefresh endpoint={`/api/events/page/${guide.id}`} /><SiteHeader /><div className="sticky top-0 border-b bg-background/95 supports-backdrop-filter:backdrop-blur"><div className="mx-auto flex min-h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6"><Link href="/" aria-label="กลับหน้ารวม" className="grid size-10 place-items-center rounded-lg hover:bg-muted"><ChevronLeftIcon /></Link><div className="relative size-11 overflow-hidden rounded-lg border"><Image src={guide.character.imageUrl} alt={guide.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="truncate font-heading font-semibold">{guide.name}</p><p className="truncate text-xs text-muted-foreground">Build Guide</p></div>{guide.character.element ? <Badge>{guide.character.element}</Badge> : null}</div><nav className="mx-auto flex min-h-11 w-full max-w-7xl gap-1 overflow-x-auto px-4 sm:px-6"><Link href={`/${guide.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", !section && "bg-muted text-foreground")}>Overview</Link>{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`} className={cn("flex items-center rounded-md px-3 text-sm text-muted-foreground", section === item.slug && "bg-muted text-foreground")}>{item.title}</Link>)}</nav></div><main className="mx-auto w-full max-w-7xl p-4 sm:p-6 lg:p-10">{section ? <GuideSection guide={guide} section={section} /> : <GuideOverview guide={guide} sections={sections} />}</main></div>;
}
function GuideOverview({ guide, sections }: { guide: PublicGuide; sections: ReturnType<typeof publicSections> }) {
return <div className="flex flex-col gap-8"><div className="relative aspect-[16/7] overflow-hidden rounded-xl border bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill priority className="object-cover" /></div><div className="mx-auto flex max-w-3xl flex-col gap-3 text-center"><p className="text-sm font-medium text-primary">Build Overview</p><h1 className="font-heading text-4xl font-semibold">{guide.name}</h1><p className="text-lg leading-8 text-muted-foreground">{guide.overview}</p></div>{sections.length ? <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">{sections.map((item) => <Link key={item.id} href={`/${guide.slug}/${item.slug}`}><Card className="h-full"><CardHeader><CardTitle>{item.title}</CardTitle><CardDescription>เปิดดูคำแนะนำฉบับเต็ม</CardDescription></CardHeader></Card></Link>)}</div> : <Card><CardHeader><CardTitle>กำลังจัดทำเนื้อหา</CardTitle><CardDescription>กลับมาตรวจสอบอีกครั้งหลังผู้ดูแลเพิ่มข้อมูล</CardDescription></CardHeader></Card>}</div>;
}
function GuideSection({ guide, section }: { guide: PublicGuide; section: string }) {
if (section === "weapon") return <WeaponSection guide={guide} />;
if (section === "artifact") return <ArtifactSection guide={guide} />;
if (section === "constellations") return <ConstellationSection guide={guide} />;
if (section === "team") return <TeamSection guide={guide} />;
const extra = guide.extras.find((item) => item.slug === section);
return extra ? <ExtraSection extra={extra} /> : null;
}
function metric(value: string | null) { return value === null ? null : `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`; }
function WeaponSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Weapons</h1></header><div className="grid gap-3 lg:grid-cols-2">{guide.weapons.map((row, index) => <Card key={row.id} size="sm"><CardContent className="flex items-center gap-4"><div className="relative size-16 shrink-0 overflow-hidden rounded-lg bg-muted"><Image src={row.weapon.imageUrl} alt={row.weapon.name} fill className="object-cover" /></div><div className="min-w-0 flex-1"><p className="font-semibold">{index + 1}. {row.weapon.name} R{row.refinement}</p><p className="text-sm text-muted-foreground">{row.weapon.weaponType}</p></div>{row.overallPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">Overall</p><p className="text-2xl font-semibold">{metric(row.overallPercent)}</p></div> : null}{row.personalPercent ? <div className="text-right"><p className="text-xs text-muted-foreground">Personal</p><p className="text-2xl font-semibold">{metric(row.personalPercent)}</p></div> : null}</CardContent></Card>)}</div></section>; }
function ArtifactSection({ guide }: { guide: PublicGuide }) { const profile = guide.artifactProfile; return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">อาร์ติแฟกต์และสเตตัสที่แนะนำ</p><h1 className="font-heading text-4xl font-semibold">Artifacts & Stats</h1></header><div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]"> <div className="flex flex-col gap-5">{guide.artifactGroups.map((group) => <Card key={group.id}><CardHeader><CardTitle>{group.name}</CardTitle></CardHeader><CardContent className="flex flex-col gap-3">{group.rows.map((row, index) => <div key={row.id} className="flex items-center gap-3"><div className="flex -space-x-3"><Image src={row.first.imageUrl} alt={row.first.name} width={56} height={56} className="rounded-lg border bg-muted" />{row.second ? <Image src={row.second.imageUrl} alt={row.second.name} width={56} height={56} className="rounded-lg border bg-muted" /> : null}</div><div className="min-w-0 flex-1"><p className="font-medium">{index + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}</p></div><p className="text-2xl font-semibold">{metric(row.percent)}</p></div>)}</CardContent></Card>)}</div>{profile ? <Card><CardHeader><CardTitle>Recommended Stats</CardTitle></CardHeader><CardContent className="flex flex-col gap-5"><StatLine label="Sands" values={profile.sands} /><StatLine label="Goblet" values={profile.goblet} /><StatLine label="Circlet" values={profile.circlet} /><Separator /><StatLine label="Substats" values={profile.substats} />{profile.targets.length ? <div><p className="mb-2 text-sm font-medium">Target Stats</p>{profile.targets.map((target) => <div key={`${target.stat}-${target.value}`} className="flex justify-between py-1 text-sm"><span>{target.stat}</span><strong>{target.value}</strong></div>)}</div> : null}{profile.talents.length ? <div><p className="mb-2 text-sm font-medium">Talent Priority</p><div className="flex gap-2">{[...profile.talents].sort((a,b) => a.priority-b.priority).map((talent) => <Badge key={talent.type} variant="secondary">{talent.priority}. {talent.type}{talent.level ? ` ${talent.level}` : ""}</Badge>)}</div></div> : null}{profile.note ? <p className="text-sm leading-6 text-muted-foreground">{profile.note}</p> : null}</CardContent></Card> : null}</div></section>; }
function StatLine({ label, values }: { label: string; values: string[] }) { return <div><p className="text-sm text-muted-foreground">{label}</p><div className="mt-1 flex flex-wrap gap-2">{values.map((value) => <Badge key={value} variant="outline">{value}</Badge>)}</div></div>; }
function ConstellationSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">กลุ่มดาว</p><h1 className="font-heading text-4xl font-semibold">Constellations</h1></header><div className="grid gap-4 md:grid-cols-2">{guide.constellations.map((row) => <Card key={row.level}><CardHeader><CardTitle>C{row.level}</CardTitle><CardDescription>{metric(row.cumulativePercent)} of C0</CardDescription></CardHeader><CardContent><p className="leading-7">{row.detail}</p></CardContent></Card>)}</div></section>; }
function TeamSection({ guide }: { guide: PublicGuide }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">คำนวณความเสียหาย</p><h1 className="font-heading text-4xl font-semibold">Team Calc</h1></header><div className="grid gap-5 xl:grid-cols-2">{guide.teams.map((team) => { const total = team.members.reduce((sum, member) => sum + Number(member.damage), 0); const seconds = Number(team.rotationSeconds); return <Card key={team.id}><CardHeader><CardTitle>{team.title}</CardTitle><CardDescription>{seconds}s rotation</CardDescription></CardHeader><CardContent className="flex flex-col gap-4"><div className="grid grid-cols-4 gap-2">{team.members.map((member) => <div key={member.position} className="text-center"><div className="relative aspect-square overflow-hidden rounded-lg bg-muted"><Image src={member.character.imageUrl} alt={member.character.name} fill className="object-cover" /></div><p className="mt-2 truncate text-sm font-medium">{member.character.name}</p><p className="text-xs text-muted-foreground">{total ? (Number(member.damage) / total * 100).toFixed(2) : "0.00"}%</p></div>)}</div><Separator /><div className="flex items-end justify-between"><div><p className="text-sm text-muted-foreground">Total damage</p><p className="font-semibold">{total.toLocaleString()}</p></div><div className="text-right"><p className="text-sm text-muted-foreground">Total DPS</p><p className="text-3xl font-semibold text-primary">{(total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 })}</p></div></div><p className="rounded-lg bg-muted p-3 text-sm leading-6">{team.combo}</p></CardContent></Card>; })}</div></section>; }
function ExtraSection({ extra }: { extra: PublicGuide["extras"][number] }) { return <section className="flex flex-col gap-6"><header className="text-center"><p className="text-sm font-medium text-primary">Extra Guide</p><h1 className="font-heading text-4xl font-semibold">{extra.title}</h1></header><div className="grid gap-5 md:grid-cols-2">{extra.rows.map((row) => <Card key={row.id} className="overflow-hidden py-0">{row.imageUrl ? <div className="relative aspect-video bg-muted"><Image src={row.imageUrl} alt={row.heading} fill className="object-cover" /></div> : null}<CardHeader className="pt-5"><CardTitle>{row.heading}</CardTitle><CardDescription>{row.body}</CardDescription>{row.percent ? <Badge>{metric(row.percent)}</Badge> : null}</CardHeader></Card>)}</div></section>; }