feat(guides): organize schemas and export teams
CI / Verify (push) Successful in 1m6s
CI / Build immutable images and deploy (push) Successful in 2m19s

This commit is contained in:
2026-08-29 18:52:01 +00:00 Unverified
parent ac9a733370
commit 17214a3625
13 changed files with 3751 additions and 46 deletions
+124
View File
@@ -0,0 +1,124 @@
/* eslint-disable @next/next/no-img-element */
import { ImageResponse } from "next/og";
import { getPublicTeamExport } from "@/lib/guides/queries";
const anuphanFont = fetch(
"https://raw.githubusercontent.com/google/fonts/main/ofl/anuphan/Anuphan%5Bwght%5D.ttf",
)
.then((response) => (response.ok ? response.arrayBuffer() : null))
.catch(() => null);
function amount(value: number): string {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
}
export async function GET(
_request: Request,
context: RouteContext<"/api/teams/[teamId]/image">,
) {
const { teamId } = await context.params;
const team = await getPublicTeamExport(teamId);
if (!team) return new Response("Team not found", { status: 404 });
const total = team.members.reduce(
(sum, member) => sum + Number(member.damage),
0,
);
const seconds = Number(team.rotationSeconds);
const dps = seconds > 0 ? total / seconds : 0;
const font = await anuphanFont;
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
color: "#f4fbfa",
background:
"radial-gradient(circle at 50% 0%, #16454a 0%, #0a2429 38%, #06171b 100%)",
border: "2px solid #3aaaa2",
padding: "34px 36px",
fontFamily: "Anuphan",
}}
>
<div style={{ display: "flex", flexDirection: "column", textAlign: "center" }}>
<div style={{ display: "flex", justifyContent: "center", fontSize: 42, fontWeight: 700 }}>
{team.guideName}
</div>
<div style={{ display: "flex", justifyContent: "center", color: "#82d6ce", fontSize: 20 }}>
{team.title} · {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })}s rotation
</div>
</div>
<div style={{ display: "flex", marginTop: 30, color: "#a6d8d4", fontSize: 22, fontWeight: 600 }}>
Damage Share
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16, marginTop: 10 }}>
{team.members.map((member) => {
const damage = Number(member.damage);
const percent = total > 0 ? (damage / total) * 100 : 0;
return (
<div key={member.position} style={{ display: "flex", alignItems: "center", gap: 14 }}>
<img
src={member.character.imageUrl}
alt=""
width="78"
height="78"
style={{ borderRadius: 16, border: "2px solid #78cbc4", objectFit: "cover" }}
/>
<div style={{ display: "flex", flex: 1, flexDirection: "column", gap: 6 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", fontSize: 20, fontWeight: 600 }}>{member.character.name}</div>
<div style={{ display: "flex", color: "#d9f6f2", fontSize: 18 }}>{amount(damage)}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ display: "flex", flex: 1, height: 24, overflow: "hidden", borderRadius: 8, background: "#17383d" }}>
<div
style={{
display: "flex",
width: `${Math.max(2, Math.min(100, percent))}%`,
height: "100%",
borderRadius: 8,
background: "linear-gradient(90deg, #55b7ae 0%, #8be0d7 100%)",
}}
/>
</div>
<div style={{ display: "flex", width: 66, justifyContent: "flex-end", fontSize: 20, fontWeight: 700 }}>
{percent.toFixed(1)}%
</div>
</div>
</div>
</div>
);
})}
</div>
<div style={{ display: "flex", flex: 1 }} />
<div style={{ display: "flex", justifyContent: "center", gap: 16, borderTop: "1px solid #28555a", paddingTop: 20, fontSize: 26, fontWeight: 700 }}>
<span style={{ color: "#f0c95f" }}>DPS: {amount(dps)}</span>
<span style={{ color: "#688c90" }}>|</span>
<span>DPR: {amount(total)}</span>
</div>
<div style={{ display: "flex", justifyContent: "center", marginTop: 14, textAlign: "center", color: "#d1e9e6", fontSize: 18, lineHeight: 1.45 }}>
{team.combo}
</div>
<div style={{ display: "flex", justifyContent: "center", marginTop: 18, color: "#5c9995", fontSize: 14 }}>
BUZZ SHEET
</div>
</div>,
{
width: 550,
height: 776,
fonts: font
? [{ name: "Anuphan", data: font, weight: 400, style: "normal" }]
: undefined,
headers: {
"Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="buzz-team-${team.id}.png"`,
},
},
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function CreateGuideFlow({ characters, existingKeys }: { characters: Char
<Field>
<FieldLabel>Guide Image</FieldLabel>
{cover ? (
<div className="relative aspect-video overflow-hidden rounded-lg border">
<div className="relative aspect-square max-w-md overflow-hidden rounded-lg border">
<Image src={cover.url} alt={`Guide cover ${selected.name}`} fill className="object-cover" />
</div>
) : null}
+1 -1
View File
@@ -21,5 +21,5 @@ export function GuideOverviewForm({ guide }: { guide: { id: string; version: num
initialVersion: guide.version,
persist: (next, expectedVersion) => saveGuideOverview(guide.id, { ...next, expectedVersion }),
});
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative aspect-video max-w-2xl overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><AutosaveStatus state={autosave} /></CardFooter></Card>;
return <Card><CardHeader><CardTitle>Overview</CardTitle><CardDescription>ภาพและข้อความนี้แสดงบนหน้าแรกและส่วนแนะนำของ Guide</CardDescription></CardHeader><CardContent><FieldGroup><Field><FieldLabel>Guide Image</FieldLabel><div className="relative aspect-square max-w-md overflow-hidden rounded-lg border"><Image src={cover.url} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><MediaUploader onReady={(asset) => setCover(asset)} /></Field><Field><FieldLabel htmlFor="guide-overview">Overview</FieldLabel><Textarea id="guide-overview" value={overview} maxLength={500} onChange={(event) => setOverview(event.target.value)} /><FieldDescription>{overview.length}/500 ตัวอักษร</FieldDescription></Field></FieldGroup></CardContent><CardFooter className="justify-end"><AutosaveStatus state={autosave} /></CardFooter></Card>;
}
+1 -1
View File
@@ -47,7 +47,7 @@ export function StructuredDashboard({ guides, trash, syncJob, catalogVersion }:
{guides.length ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{guides.map((guide) => (
<Card key={guide.id} className="overflow-hidden py-0">
<div className="relative aspect-video bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div>
<div className="relative aspect-square bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div>
<CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-2">{guide.overview}</CardDescription><CardAction><Badge variant="secondary">v{guide.version}</Badge></CardAction></CardHeader>
<CardContent><p className="text-xs text-muted-foreground">แก้ไขล่าสุด {new Date(guide.updatedAt).toLocaleString("th-TH")}</p></CardContent>
<CardFooter className="justify-between pb-5"><Button render={<Link href={`/admin/${guide.characterKey}`} />} nativeButton={false}>แก้ไข Guide</Button><AlertDialog><AlertDialogTrigger render={<Button variant="ghost" size="icon" />}><Trash2Icon /><span className="sr-only">ย้าย {guide.name} ไปถังขยะ</span></AlertDialogTrigger><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>ย้าย {guide.name} ไปถังขยะ?</AlertDialogTitle><AlertDialogDescription>Guide จะหายจากหน้าเว็บไซต์ทันที และสามารถกู้คืนได้ภายหลัง</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>ยกเลิก</AlertDialogCancel><AlertDialogAction onClick={() => run(() => moveGuideToTrash(guide.id), "ย้ายไปถังขยะแล้ว")}>ย้ายไปถังขยะ</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog></CardFooter>
+1 -1
View File
@@ -16,5 +16,5 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
const [query, setQuery] = useState("");
const normalized = query.trim().toLowerCase();
const filtered = guides.filter((guide) => `${guide.name} ${guide.overview}`.toLowerCase().includes(normalized));
return <div className="flex flex-col gap-8"><InputGroup className="h-11 max-w-2xl"><InputGroupAddon><SearchIcon /></InputGroupAddon><InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหา Guide…" aria-label="ค้นหา Guide" /></InputGroup>{filtered.length ? <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">{filtered.map((guide) => <Link key={guide.id} href={`/${guide.slug}`} className="group rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5"><div className="relative aspect-video bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-3">{guide.overview}</CardDescription></CardHeader><CardContent className="pb-5"><Badge variant="secondary">Build Guide</Badge></CardContent></Card></Link>)}</div> : <Empty className="border"><EmptyHeader><EmptyTitle>ไม่พบ Guide</EmptyTitle><EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription></EmptyHeader></Empty>}</div>;
return <div className="flex flex-col gap-8"><InputGroup className="h-11 max-w-2xl"><InputGroupAddon><SearchIcon /></InputGroupAddon><InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหา Guide…" aria-label="ค้นหา Guide" /></InputGroup>{filtered.length ? <div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">{filtered.map((guide) => <Link key={guide.id} href={`/${guide.slug}`} className="group rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring"><Card className="h-full overflow-hidden py-0 transition-transform group-hover:-translate-y-0.5"><div className="relative aspect-square bg-muted"><Image src={guide.coverUrl} alt={`Guide ${guide.name}`} fill className="object-cover" /></div><CardHeader className="pt-5"><CardTitle>{guide.name}</CardTitle><CardDescription className="line-clamp-3">{guide.overview}</CardDescription></CardHeader><CardContent className="pb-5"><Badge variant="secondary">Build Guide</Badge></CardContent></Card></Link>)}</div> : <Empty className="border"><EmptyHeader><EmptyTitle>ไม่พบ Guide</EmptyTitle><EmptyDescription>ลองค้นหาด้วยชื่อตัวละครอื่น</EmptyDescription></EmptyHeader></Empty>}</div>;
}
+6 -5
View File
@@ -1,9 +1,10 @@
import Image from "next/image";
import Link from "next/link";
import { ChevronLeftIcon } from "lucide-react";
import { ChevronLeftIcon, DownloadIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardAction, 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";
@@ -28,7 +29,7 @@ export function StructuredGuideShell({ guide, section }: { guide: PublicGuide; s
}
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>;
return <div className="flex flex-col gap-8"><div className="relative mx-auto aspect-square w-full max-w-3xl 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 }) {
@@ -42,7 +43,7 @@ function GuideSection({ guide, section }: { guide: PublicGuide; section: string
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 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" className={cn(row.weapon.rarity === 5 && "bg-[linear-gradient(90deg,rgba(138,90,0,1)_0%,rgba(15,8,0,1)_100%)]", row.weapon.rarity === 4 && "bg-gradient-to-r from-[#a300b8] to-black")}><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">ดาเมทโดยรวม</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">ดาเมทส่วนตัว</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>; }
@@ -50,6 +51,6 @@ function StatLine({ label, values }: { label: string; values: string[] }) { retu
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>{row.iconUrl ? <Image src={row.iconUrl} alt="" width={48} height={48} className="rounded-lg bg-muted" /> : null}<CardTitle>C{row.level} · {row.name}</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-sm font-semibold">{Number(member.damage).toLocaleString(undefined, { maximumFractionDigits: 2 })}</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 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><CardAction><a href={`/api/teams/${team.id}/image`} download className={buttonVariants({ variant: "outline", size: "sm" })}><DownloadIcon data-icon="inline-start" />Export image</a></CardAction></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-sm font-semibold">{Number(member.damage).toLocaleString(undefined, { maximumFractionDigits: 2 })}</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">{seconds > 0 ? (total / seconds).toLocaleString(undefined, { maximumFractionDigits: 0 }) : "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>; }
+41 -34
View File
@@ -6,7 +6,7 @@ import {
integer,
jsonb,
numeric,
pgTable,
pgSchema,
primaryKey,
text,
timestamp,
@@ -17,6 +17,13 @@ import {
import type { CatalogConstellation } from "@/lib/catalog/lunaris";
const authSchema = pgSchema("auth");
const mediaSchema = pgSchema("media");
const editorSchema = pgSchema("editor");
const catalogSchema = pgSchema("catalog");
const guidesSchema = pgSchema("guides");
const operationsSchema = pgSchema("operations");
const timestamps = {
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
@@ -29,7 +36,7 @@ const timestamps = {
// Better Auth core tables. Credential registration is restricted to
// ADMIN_EMAIL, which is rechecked at every protected application boundary.
export const users = pgTable(
export const users = authSchema.table(
"user",
{
id: text("id").primaryKey(),
@@ -42,7 +49,7 @@ export const users = pgTable(
(table) => [uniqueIndex("user_email_unique").on(table.email)],
);
export const sessions = pgTable(
export const sessions = authSchema.table(
"session",
{
id: text("id").primaryKey(),
@@ -61,7 +68,7 @@ export const sessions = pgTable(
],
);
export const accounts = pgTable(
export const accounts = authSchema.table(
"account",
{
id: text("id").primaryKey(),
@@ -90,7 +97,7 @@ export const accounts = pgTable(
],
);
export const verifications = pgTable(
export const verifications = authSchema.table(
"verification",
{
id: text("id").primaryKey(),
@@ -102,7 +109,7 @@ export const verifications = pgTable(
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const media = pgTable(
export const media = mediaSchema.table(
"media",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -134,7 +141,7 @@ export const media = pgTable(
],
);
export const characters = pgTable(
export const characters = editorSchema.table(
"character",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -158,7 +165,7 @@ export const characters = pgTable(
],
);
export const pages = pgTable(
export const pages = editorSchema.table(
"page",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -186,7 +193,7 @@ export const pages = pgTable(
],
);
export const blocks = pgTable(
export const blocks = editorSchema.table(
"block",
{
id: uuid("id").primaryKey(),
@@ -206,7 +213,7 @@ export const blocks = pgTable(
(table) => [index("block_page_order_idx").on(table.pageId, table.sortOrder)],
);
export const dataSources = pgTable(
export const dataSources = editorSchema.table(
"data_source",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -219,7 +226,7 @@ export const dataSources = pgTable(
(table) => [index("data_source_name_idx").on(table.name)],
);
export const dataSourceVersions = pgTable(
export const dataSourceVersions = editorSchema.table(
"data_source_version",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -244,7 +251,7 @@ export const dataSourceVersions = pgTable(
],
);
export const pageDataSources = pgTable(
export const pageDataSources = editorSchema.table(
"page_data_source",
{
pageId: uuid("page_id")
@@ -261,7 +268,7 @@ export const pageDataSources = pgTable(
],
);
export const templates = pgTable(
export const templates = editorSchema.table(
"template",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -276,7 +283,7 @@ export const templates = pgTable(
(table) => [uniqueIndex("template_slug_unique").on(table.slug)],
);
export const revisions = pgTable(
export const revisions = editorSchema.table(
"revision",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -310,7 +317,7 @@ export const revisions = pgTable(
],
);
export const publicSnapshots = pgTable(
export const publicSnapshots = editorSchema.table(
"public_snapshot",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -343,7 +350,7 @@ export const publicSnapshots = pgTable(
],
);
export const slugAliases = pgTable(
export const slugAliases = editorSchema.table(
"slug_alias",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -368,7 +375,7 @@ export const slugAliases = pgTable(
],
);
export const mediaReferences = pgTable(
export const mediaReferences = mediaSchema.table(
"media_reference",
{
mediaId: uuid("media_id")
@@ -394,7 +401,7 @@ export const mediaReferences = pgTable(
],
);
export const outboxEvents = pgTable(
export const outboxEvents = operationsSchema.table(
"outbox_event",
{
id: bigint("id", { mode: "number" })
@@ -424,7 +431,7 @@ export const outboxEvents = pgTable(
],
);
export const catalogVersions = pgTable(
export const catalogVersions = catalogSchema.table(
"catalog_version",
{
version: varchar("version", { length: 32 }).primaryKey(),
@@ -435,7 +442,7 @@ export const catalogVersions = pgTable(
(table) => [uniqueIndex("catalog_active_unique").on(table.active).where(sql`${table.active} = true`)],
);
export const catalogCharacters = pgTable(
export const catalogCharacters = catalogSchema.table(
"catalog_character",
{
version: varchar("version", { length: 32 })
@@ -455,7 +462,7 @@ export const catalogCharacters = pgTable(
],
);
export const catalogWeapons = pgTable(
export const catalogWeapons = catalogSchema.table(
"catalog_weapon",
{
version: varchar("version", { length: 32 })
@@ -473,7 +480,7 @@ export const catalogWeapons = pgTable(
],
);
export const catalogArtifacts = pgTable(
export const catalogArtifacts = catalogSchema.table(
"catalog_artifact",
{
version: varchar("version", { length: 32 })
@@ -489,7 +496,7 @@ export const catalogArtifacts = pgTable(
],
);
export const catalogSyncJobs = pgTable(
export const catalogSyncJobs = catalogSchema.table(
"catalog_sync_job",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -506,7 +513,7 @@ export const catalogSyncJobs = pgTable(
(table) => [index("catalog_sync_status_idx").on(table.status, table.createdAt)],
);
export const guides = pgTable(
export const guides = guidesSchema.table(
"guide",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -532,7 +539,7 @@ export const guides = pgTable(
],
);
export const guideSections = pgTable(
export const guideSections = guidesSchema.table(
"guide_section",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -549,7 +556,7 @@ export const guideSections = pgTable(
],
);
export const weaponRecommendations = pgTable(
export const weaponRecommendations = guidesSchema.table(
"weapon_recommendation",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -564,7 +571,7 @@ export const weaponRecommendations = pgTable(
(table) => [index("weapon_recommendation_order_idx").on(table.guideId, table.sortOrder)],
);
export const artifactGroups = pgTable(
export const artifactGroups = guidesSchema.table(
"artifact_group",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -575,7 +582,7 @@ export const artifactGroups = pgTable(
(table) => [index("artifact_group_order_idx").on(table.guideId, table.sortOrder)],
);
export const artifactRecommendations = pgTable(
export const artifactRecommendations = guidesSchema.table(
"artifact_recommendation",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -589,7 +596,7 @@ export const artifactRecommendations = pgTable(
(table) => [index("artifact_recommendation_order_idx").on(table.groupId, table.sortOrder)],
);
export const artifactProfiles = pgTable("artifact_profile", {
export const artifactProfiles = guidesSchema.table("artifact_profile", {
guideId: uuid("guide_id").primaryKey().references(() => guides.id, { onDelete: "cascade" }),
sands: jsonb("sands").$type<string[]>().default([]).notNull(),
goblet: jsonb("goblet").$type<string[]>().default([]).notNull(),
@@ -601,7 +608,7 @@ export const artifactProfiles = pgTable("artifact_profile", {
...timestamps,
});
export const constellationRecommendations = pgTable(
export const constellationRecommendations = guidesSchema.table(
"constellation_recommendation",
{
guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }),
@@ -612,7 +619,7 @@ export const constellationRecommendations = pgTable(
(table) => [primaryKey({ columns: [table.guideId, table.level] })],
);
export const teamCompositions = pgTable(
export const teamCompositions = guidesSchema.table(
"team_composition",
{
id: uuid("id").defaultRandom().primaryKey(),
@@ -625,7 +632,7 @@ export const teamCompositions = pgTable(
(table) => [index("team_composition_order_idx").on(table.guideId, table.sortOrder)],
);
export const teamMembers = pgTable(
export const teamMembers = guidesSchema.table(
"team_member",
{
teamId: uuid("team_id").notNull().references(() => teamCompositions.id, { onDelete: "cascade" }),
@@ -637,12 +644,12 @@ export const teamMembers = pgTable(
(table) => [primaryKey({ columns: [table.teamId, table.position] })],
);
export const extraSections = pgTable("extra_section", {
export const extraSections = guidesSchema.table("extra_section", {
sectionId: uuid("section_id").primaryKey().references(() => guideSections.id, { onDelete: "cascade" }),
...timestamps,
});
export const extraSectionRows = pgTable(
export const extraSectionRows = guidesSchema.table(
"extra_section_row",
{
id: uuid("id").defaultRandom().primaryKey(),
+77
View File
@@ -0,0 +1,77 @@
CREATE SCHEMA IF NOT EXISTS "auth";
--> statement-breakpoint
CREATE SCHEMA IF NOT EXISTS "catalog";
--> statement-breakpoint
CREATE SCHEMA IF NOT EXISTS "editor";
--> statement-breakpoint
CREATE SCHEMA IF NOT EXISTS "guides";
--> statement-breakpoint
CREATE SCHEMA IF NOT EXISTS "media";
--> statement-breakpoint
CREATE SCHEMA IF NOT EXISTS "operations";
--> statement-breakpoint
ALTER TABLE "public"."account" SET SCHEMA "auth";
--> statement-breakpoint
ALTER TABLE "public"."artifact_group" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."artifact_profile" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."artifact_recommendation" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."block" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."catalog_artifact" SET SCHEMA "catalog";
--> statement-breakpoint
ALTER TABLE "public"."catalog_character" SET SCHEMA "catalog";
--> statement-breakpoint
ALTER TABLE "public"."catalog_sync_job" SET SCHEMA "catalog";
--> statement-breakpoint
ALTER TABLE "public"."catalog_version" SET SCHEMA "catalog";
--> statement-breakpoint
ALTER TABLE "public"."catalog_weapon" SET SCHEMA "catalog";
--> statement-breakpoint
ALTER TABLE "public"."character" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."constellation_recommendation" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."data_source_version" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."data_source" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."extra_section_row" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."extra_section" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."guide_section" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."guide" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."media" SET SCHEMA "media";
--> statement-breakpoint
ALTER TABLE "public"."media_reference" SET SCHEMA "media";
--> statement-breakpoint
ALTER TABLE "public"."outbox_event" SET SCHEMA "operations";
--> statement-breakpoint
ALTER TABLE "public"."page_data_source" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."page" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."public_snapshot" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."revision" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."session" SET SCHEMA "auth";
--> statement-breakpoint
ALTER TABLE "public"."slug_alias" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."team_composition" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."team_member" SET SCHEMA "guides";
--> statement-breakpoint
ALTER TABLE "public"."template" SET SCHEMA "editor";
--> statement-breakpoint
ALTER TABLE "public"."user" SET SCHEMA "auth";
--> statement-breakpoint
ALTER TABLE "public"."verification" SET SCHEMA "auth";
--> statement-breakpoint
ALTER TABLE "public"."weapon_recommendation" SET SCHEMA "guides";
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1788027009371,
"tag": "0003_right_exiles",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1788029234044,
"tag": "0004_mixed_felicia_hardy",
"breakpoints": true
}
]
}
+11 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { cleanLunarisText, constellationSnapshot } from "@/lib/catalog/lunaris";
import {
characterDetailSchema,
cleanLunarisText,
constellationSnapshot,
} from "@/lib/catalog/lunaris";
describe("Lunaris character data", () => {
it("removes display markup from constellation text", () => {
@@ -20,4 +24,10 @@ describe("Lunaris character data", () => {
iconKey: "catalog/1/constellations/UI_Talent_Test_01.webp",
}]);
});
it("accepts characters that do not have constellations", () => {
const detail = characterDetailSchema.parse({ constellations: null });
expect(constellationSnapshot(detail, (icon) => icon)).toEqual([]);
});
});
+2 -2
View File
@@ -14,7 +14,7 @@ const constellationSchema = z.object({
});
export const characterDetailSchema = z.object({
constellations: z.record(z.string(), constellationSchema),
constellations: z.record(z.string(), constellationSchema).nullish(),
});
export function cleanLunarisText(value: string): string {
@@ -32,7 +32,7 @@ export function constellationSnapshot(
const result: CatalogConstellation[] = [];
for (const index of Array.from({ length: 6 }, (_, itemIndex) => itemIndex)) {
const level = index + 1;
const item = value.constellations[`const${level}`];
const item = value.constellations?.[`const${level}`];
if (!item) continue;
result.push({
level,
+40
View File
@@ -125,4 +125,44 @@ export async function getLatestCatalogSyncJob() {
return job ?? null;
}
export async function getPublicTeamExport(teamId: string) {
const [row] = await getDb()
.select({ team: teamCompositions, guide: guides })
.from(teamCompositions)
.innerJoin(guides, eq(guides.id, teamCompositions.guideId))
.innerJoin(
guideSections,
and(
eq(guideSections.guideId, guides.id),
eq(guideSections.slug, "team"),
eq(guideSections.enabled, true),
),
)
.where(and(eq(teamCompositions.id, teamId), isNull(guides.trashedAt)))
.limit(1);
if (!row) return null;
const members = await getDb()
.select({ member: teamMembers, character: catalogCharacters })
.from(teamMembers)
.innerJoin(
catalogCharacters,
and(
eq(catalogCharacters.version, teamMembers.catalogVersion),
eq(catalogCharacters.key, teamMembers.characterKey),
),
)
.where(eq(teamMembers.teamId, teamId))
.orderBy(asc(teamMembers.position));
return {
...row.team,
guideName: row.guide.name,
members: members.map(({ member, character }) => ({
...member,
character: catalogCharacterWithUrls(character),
})),
};
}
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;