feat(guides): organize schemas and export teams
This commit is contained in:
@@ -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"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>>;
|
||||
|
||||
Reference in New Issue
Block a user