fix : fix
This commit is contained in:
@@ -22,5 +22,5 @@ export default async function StructuredEditorPage({ params }: { params: Promise
|
||||
const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]);
|
||||
if (!catalog || !content) notFound();
|
||||
const guideCharacter = catalog.characters.find((item) => item.key === guide.characterKey);
|
||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
|
||||
return <div className="min-h-svh"><AdminHeader /><main className="mx-auto flex w-full max-w-6xl flex-col gap-6 p-4 sm:p-6 lg:p-8"><header className="flex items-center gap-4"><Button variant="outline" size="icon" render={<Link href={`/admin/${character}`} />} nativeButton={false} aria-label="กลับหน้า Guide"><ArrowLeftIcon /></Button><div className="min-w-0"><p className="text-sm text-muted-foreground">{guide.name}</p><h1 className="whitespace-nowrap font-heading text-3xl font-semibold">{guide.sections.find((section) => section.slug === page)?.title ?? page}</h1></div></header><GuideSectionNav characterKey={guide.characterKey} current={page as (typeof sections)[number]} /><GuideSectionEditor section={page as (typeof sections)[number]} guide={guide} weapons={catalog.weapons} artifacts={catalog.artifacts} characters={catalog.characters} catalogConstellations={guideCharacter?.constellations ?? []} catalogTalents={guideCharacter?.talents ?? []} initial={{ weapons: content.weapons, artifactGroups: content.artifactGroups.map((group) => ({ id: group.id, name: group.name, rows: group.rows.map((row) => ({ firstArtifactKey: row.firstArtifactKey, secondArtifactKey: row.secondArtifactKey, percent: row.percent })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ title: team.title, rotationSeconds: team.rotationSeconds, combo: team.combo, members: team.members.map((member) => ({ characterKey: member.characterKey, weaponKey: member.weaponKey, artifactKey: member.artifactKey, constellation: member.constellation, refinement: member.refinement, damage: member.damage })) })), extras: content.extras.map((section) => ({ title: section.title, rows: section.rows.map((row) => ({ mediaId: row.mediaId, heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} /></main></div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { ImageResponse } from "next/og";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { rarityGradient } from "@/lib/catalog/rarity";
|
||||
import { imageContentDisposition } from "@/lib/guides/export-image";
|
||||
import { getPublicArtifactExport } from "@/lib/guides/queries";
|
||||
import { normalizeTalentPriorities } from "@/lib/guides/talents";
|
||||
|
||||
const anuphanFonts = Promise.all(
|
||||
[
|
||||
{ subset: "latin", weight: 400 as const },
|
||||
{ subset: "thai", weight: 400 as const },
|
||||
{ subset: "latin", weight: 700 as const },
|
||||
{ subset: "thai", weight: 700 as const },
|
||||
].map(async ({ subset, weight }) => {
|
||||
const response = await fetch(
|
||||
`https://unpkg.com/@fontsource/[email protected]/files/anuphan-${subset}-${weight}-normal.woff`,
|
||||
);
|
||||
if (!response.ok) throw new Error(`Unable to load Anuphan ${subset} ${weight}`);
|
||||
return {
|
||||
name: "Anuphan",
|
||||
data: await response.arrayBuffer(),
|
||||
weight,
|
||||
style: "normal" as const,
|
||||
};
|
||||
}),
|
||||
).catch(() => []);
|
||||
|
||||
function metric(value: string): string {
|
||||
return `${Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 })}%`;
|
||||
}
|
||||
|
||||
function statLabel(value: string): string {
|
||||
return value === "EM"
|
||||
? "Elemental Mastery"
|
||||
: value === "ER"
|
||||
? "Energy Recharge"
|
||||
: value;
|
||||
}
|
||||
|
||||
async function pngDataUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const png = await sharp(Buffer.from(await response.arrayBuffer()))
|
||||
.resize(96, 96, { fit: "contain" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
return `data:image/png;base64,${png.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: RouteContext<"/api/guides/[guideId]/artifacts/image">,
|
||||
) {
|
||||
const { guideId } = await context.params;
|
||||
const guide = await getPublicArtifactExport(guideId);
|
||||
if (!guide) return new Response("Guide not found", { status: 404 });
|
||||
|
||||
const [fonts, groupAssets] = await Promise.all([
|
||||
anuphanFonts,
|
||||
Promise.all(
|
||||
guide.groups.map((group) =>
|
||||
Promise.all(
|
||||
group.rows.map(async (row) => ({
|
||||
first: await pngDataUrl(row.first.imageUrl),
|
||||
second: row.second ? await pngDataUrl(row.second.imageUrl) : null,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const profile = guide.profile;
|
||||
const leftHeight = guide.groups.reduce((sum, group) => sum + 68 + group.rows.length * 76, 0) + Math.max(0, guide.groups.length - 1) * 16;
|
||||
const detailHeight = profile
|
||||
? 60 + profile.targets.length * 30 + (profile.talents.length ? 58 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0)
|
||||
: 0;
|
||||
const rightHeight = profile
|
||||
? 194 + (profile.substats.length ? 76 : 0) + (detailHeight > 60 ? detailHeight + 16 : 0) + 32
|
||||
: 0;
|
||||
const height = Math.max(520, 190 + Math.max(leftHeight, rightHeight));
|
||||
const talents = normalizeTalentPriorities(profile?.talents ?? []);
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 20,
|
||||
color: "#f7f7f7",
|
||||
background: "#05060a",
|
||||
padding: "32px 36px",
|
||||
fontFamily: "Anuphan",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
|
||||
<div style={{ display: "flex", color: "#f0b72f", fontSize: 16, fontWeight: 700 }}>
|
||||
อาร์ติแฟกต์และสเตตัสที่แนะนำ
|
||||
</div>
|
||||
<div style={{ display: "flex", fontSize: 38, fontWeight: 700 }}>
|
||||
Artifacts & Stats
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 16 }}>
|
||||
<div style={{ display: "flex", flex: profile ? 1.2 : 1, flexDirection: "column", gap: 16 }}>
|
||||
{guide.groups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.id}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
border: "1px solid #34343d",
|
||||
borderRadius: 14,
|
||||
background: "#1d1d25",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", padding: "16px 20px", fontSize: 22, fontWeight: 700 }}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", padding: "0 20px 14px" }}>
|
||||
{group.rows.map((row, rowIndex) => (
|
||||
<div key={row.id} style={{ display: "flex", minHeight: 76, alignItems: "center", gap: 14 }}>
|
||||
<div style={{ display: "flex", width: row.second ? 94 : 58 }}>
|
||||
{groupAssets[groupIndex][rowIndex].first ? (
|
||||
<div style={{ display: "flex", width: 58, height: 58, overflow: "hidden", border: "1px solid #ffffff2a", borderRadius: 10, background: rarityGradient(row.first.rarity) }}>
|
||||
<img src={groupAssets[groupIndex][rowIndex].first!} alt="" width={58} height={58} style={{ objectFit: "contain" }} />
|
||||
</div>
|
||||
) : null}
|
||||
{row.second && groupAssets[groupIndex][rowIndex].second ? (
|
||||
<div style={{ display: "flex", width: 58, height: 58, overflow: "hidden", marginLeft: -22, border: "1px solid #ffffff2a", borderRadius: 10, background: rarityGradient(row.second.rarity) }}>
|
||||
<img src={groupAssets[groupIndex][rowIndex].second!} alt="" width={58} height={58} style={{ objectFit: "contain" }} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: "flex", flex: 1, fontSize: 18, fontWeight: 700 }}>
|
||||
{rowIndex + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""}
|
||||
</div>
|
||||
<div style={{ display: "flex", fontSize: 26, fontWeight: 700 }}>
|
||||
{metric(row.percent)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{profile ? (
|
||||
<div style={{ display: "flex", flex: 1, flexDirection: "column", gap: 16 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", overflow: "hidden", border: "1px solid #34343d", borderRadius: 14, background: "#1d1d25" }}>
|
||||
<div style={{ display: "flex", justifyContent: "center", background: "#ddb45a", color: "#211a0d", padding: "14px 20px", fontSize: 24, fontWeight: 700 }}>
|
||||
Artifact Stats
|
||||
</div>
|
||||
<div style={{ display: "flex" }}>
|
||||
{[
|
||||
{ label: "Sands", values: profile.sands },
|
||||
{ label: "Goblet", values: profile.goblet },
|
||||
{ label: "Circlet", values: profile.circlet },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} style={{ display: "flex", flex: 1, minHeight: 128, flexDirection: "column", alignItems: "center", gap: 10, borderRight: stat.label === "Circlet" ? "none" : "1px solid #34343d", padding: "16px 10px", textAlign: "center" }}>
|
||||
<div style={{ display: "flex", fontSize: 17, fontWeight: 700 }}>{stat.label}</div>
|
||||
<div style={{ display: "flex", width: "100%", borderTop: "1px solid #34343d" }} />
|
||||
<div style={{ display: "flex", fontSize: 14, lineHeight: 1.45 }}>
|
||||
{stat.values.length ? stat.values.map(statLabel).join(" > ") : "—"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile.substats.length ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", border: "1px solid #34343d", borderRadius: 14, background: "#1d1d25", padding: "20px", fontSize: 17, textAlign: "center" }}>
|
||||
<strong>Stat Priority: </strong>{profile.substats.map(statLabel).join(" > ")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailHeight > 60 ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14, border: "1px solid #34343d", borderRadius: 14, background: "#1d1d25", padding: "18px 20px" }}>
|
||||
<div style={{ display: "flex", fontSize: 20, fontWeight: 700 }}>รายละเอียดเพิ่มเติม</div>
|
||||
{profile.targets.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
||||
<div style={{ display: "flex", color: "#b9b9c2", fontSize: 14, fontWeight: 700 }}>Target Stats</div>
|
||||
{profile.targets.map((target) => (
|
||||
<div key={`${target.stat}-${target.value}`} style={{ display: "flex", justifyContent: "space-between", fontSize: 15 }}>
|
||||
<span>{target.stat}</span><strong>{target.value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{talents.length ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
<div style={{ display: "flex", color: "#b9b9c2", fontSize: 14, fontWeight: 700 }}>ลำดับการอัพพรสวรรค์</div>
|
||||
<div style={{ display: "flex", gap: 10, fontSize: 17, fontWeight: 700 }}>
|
||||
{talents.map((talent, index) => (
|
||||
<div key={talent.type} style={{ display: "flex", gap: 10 }}>
|
||||
<span>{talent.type}{talent.level ? ` Lv.${talent.level}+` : ""}</span>
|
||||
{index < talents.length - 1 ? <span>{talent.relationToNext === "gte" ? ">=" : ">"}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{profile.note ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||
<div style={{ display: "flex", color: "#b9b9c2", fontSize: 14, fontWeight: 700 }}>โน็ต</div>
|
||||
<div style={{ display: "flex", fontSize: 14, lineHeight: 1.55 }}>{profile.note}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height,
|
||||
fonts: fonts.length ? fonts : undefined,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": imageContentDisposition(`${guide.guideName} Artifacts`, "artifacts"),
|
||||
"Content-Type": "image/png",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useAutosave } from "@/hooks/use-autosave";
|
||||
import {
|
||||
@@ -72,7 +73,7 @@ type Guide = { id: string; characterKey: string; version: number; name: string }
|
||||
type WeaponValue = { weaponKey: string; refinement: number; overallPercent: string | null; personalPercent: string | null; weapon: CatalogItem };
|
||||
type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: string; secondArtifactKey: string | null; percent: string }> };
|
||||
type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: TalentPriority[]; note: string | null } | null;
|
||||
type ConstellationValue = { level: number; detail: string; cumulativePercent: string };
|
||||
type ConstellationValue = { level: number; detail: string; cumulativePercent: string; isGreat: boolean };
|
||||
type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null };
|
||||
type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; iconUrl: string | null };
|
||||
type TeamValue = { title: string; rotationSeconds: string; combo: string; members: Array<{ characterKey: string; weaponKey: string | null; artifactKey: string | null; constellation: number; refinement: number; damage: string }> };
|
||||
@@ -336,15 +337,18 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas
|
||||
function ConstellationEditor({ guide, initial, catalogConstellations }: EditorBase) {
|
||||
const [rows, setRows] = useState(() => {
|
||||
const source = catalogConstellations.length ? catalogConstellations : Array.from({ length: 6 }, (_, index) => ({ level: index + 1, name: `C${index + 1}`, description: initial.constellations.find((row) => row.level === index + 1)?.detail ?? "", iconUrl: null }));
|
||||
return source.map((constellation) => ({ ...constellation, cumulativePercent: initial.constellations.find((row) => row.level === constellation.level)?.cumulativePercent ?? "" }));
|
||||
return source.map((constellation) => {
|
||||
const saved = initial.constellations.find((row) => row.level === constellation.level);
|
||||
return { ...constellation, cumulativePercent: saved?.cumulativePercent ?? "", isGreat: saved?.isGreat ?? false };
|
||||
});
|
||||
});
|
||||
const value = { rows: rows.filter((row) => row.description.trim() && row.cumulativePercent !== "").map((row) => ({ level: row.level, detail: row.description, cumulativePercent: Number(row.cumulativePercent) })) };
|
||||
const value = { rows: rows.filter((row) => row.description.trim() && row.cumulativePercent !== "").map((row) => ({ level: row.level, detail: row.description, cumulativePercent: Number(row.cumulativePercent), isGreat: row.isGreat })) };
|
||||
const autosave = useAutosave({ value, valid: constellationSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveConstellationSection(guide.id, { ...next, expectedVersion }) });
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Constellations</CardTitle><CardDescription>ชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100%</CardDescription></CardHeader>
|
||||
<CardContent className="grid gap-4 lg:grid-cols-2">
|
||||
{rows.map((row, index) => <Card key={row.level} size="sm"><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>{row.description}</CardDescription></CardHeader><CardContent><Field><FieldLabel>Cumulative %</FieldLabel><Input type="number" min="0" step="0.01" value={row.cumulativePercent} onChange={(event) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /></Field></CardContent></Card>)}
|
||||
{rows.map((row, index) => <Card key={row.level} size="sm"><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>{row.description}</CardDescription></CardHeader><CardContent className="flex items-end gap-3"><Field className="min-w-0 flex-1"><FieldLabel>Cumulative %</FieldLabel><Input type="number" min="0" step="0.01" value={row.cumulativePercent} onChange={(event) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /></Field><Toggle variant="outline" pressed={row.isGreat} onPressedChange={(isGreat) => setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, isGreat } : item))} aria-label={`Mark C${row.level} percentage as great`} className="data-[state=on]:text-warning"><SparklesIcon data-icon="inline-start" />Great %</Toggle></CardContent></Card>)}
|
||||
</CardContent>
|
||||
<CardFooter className="justify-end"><AutosaveStatus state={autosave} /></CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function GuideSectionNav({
|
||||
}) {
|
||||
return (
|
||||
<Pagination className="justify-start overflow-x-auto rounded-xl border bg-card p-2">
|
||||
<PaginationContent>
|
||||
<PaginationContent className="whitespace-nowrap">
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
href={`/admin/${characterKey}`}
|
||||
|
||||
@@ -66,31 +66,31 @@ export function StructuredGuideShell({
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Link>
|
||||
<div className="relative size-11 overflow-hidden rounded-lg border">
|
||||
<div className={cn("relative size-11 overflow-hidden rounded-lg border", rarityGradientClass(guide.character.rarity))}>
|
||||
<Image
|
||||
src={guide.character.imageUrl}
|
||||
alt={guide.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-1">
|
||||
<p className="truncate font-heading font-semibold">{guide.name}</p>
|
||||
{guide.character.element ? (
|
||||
<Badge>
|
||||
{guide.character.elementIconUrl ? (
|
||||
<ElementIcon element={guide.character.element} url={guide.character.elementIconUrl} />
|
||||
) : null}
|
||||
{guide.character.element}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{guide.character.element ? (
|
||||
<Badge>
|
||||
{guide.character.elementIconUrl ? (
|
||||
<ElementIcon element={guide.character.element} url={guide.character.elementIconUrl} />
|
||||
) : null}
|
||||
{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",
|
||||
"flex shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm text-muted-foreground",
|
||||
!section && "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -101,7 +101,7 @@ export function StructuredGuideShell({
|
||||
key={item.id}
|
||||
href={`/${guide.slug}/${item.slug}`}
|
||||
className={cn(
|
||||
"flex items-center rounded-md px-3 text-sm text-muted-foreground",
|
||||
"flex shrink-0 items-center whitespace-nowrap rounded-md px-3 text-sm text-muted-foreground",
|
||||
section === item.slug && "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -200,7 +200,7 @@ function WeaponSection({ guide }: { guide: PublicGuide }) {
|
||||
<section className="flex flex-col gap-6">
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-sm font-medium text-primary">อาวุธที่แนะนำ</p>
|
||||
<h1 className="font-heading text-4xl font-semibold">Weapons</h1>
|
||||
<h1 className="whitespace-nowrap font-heading text-4xl font-semibold">Weapons</h1>
|
||||
<a
|
||||
href={`/api/guides/${guide.id}/weapons/image`}
|
||||
download
|
||||
@@ -272,13 +272,21 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
|
||||
const profile = guide.artifactProfile;
|
||||
return (
|
||||
<section className="flex flex-col gap-6">
|
||||
<header className="text-center">
|
||||
<header className="flex flex-col items-center gap-3 text-center">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
อาร์ติแฟกต์และสเตตัสที่แนะนำ
|
||||
</p>
|
||||
<h1 className="font-heading text-4xl font-semibold">
|
||||
Artifacts & Stats
|
||||
</h1>
|
||||
<a
|
||||
href={`/api/guides/${guide.id}/artifacts/image`}
|
||||
download
|
||||
className={buttonVariants({ variant: "outline", size: "sm" })}
|
||||
>
|
||||
<DownloadIcon data-icon="inline-start" />
|
||||
Export image
|
||||
</a>
|
||||
</header>
|
||||
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
|
||||
{" "}
|
||||
@@ -375,22 +383,22 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
|
||||
{profile.talents.length ? (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">ลำดับการอัพพรสวรรค์</p>
|
||||
<div className="flex flex-wrap items-start justify-center gap-3">
|
||||
<div className="flex items-start justify-center gap-1 sm:gap-3">
|
||||
{normalizeTalentPriorities(profile.talents)
|
||||
.map((talent, index, talents) => {
|
||||
const catalogTalent = guide.character.talents.find((item) => item.type === talent.type);
|
||||
const talentName = catalogTalent?.name ?? talent.type;
|
||||
return (
|
||||
<div key={talent.type} className="flex items-start gap-3">
|
||||
<div className="flex w-28 flex-col items-center gap-2 text-center">
|
||||
<div className="relative size-20 overflow-hidden rounded-xl border bg-muted">
|
||||
<div key={talent.type} className="flex min-w-0 items-start gap-1 sm:gap-3">
|
||||
<div className="flex w-16 flex-col items-center gap-2 text-center sm:w-28">
|
||||
<div className="relative size-14 overflow-hidden rounded-xl border bg-muted sm:size-20">
|
||||
{catalogTalent?.iconUrl ? <Image src={catalogTalent.iconUrl} alt={talentName} fill className="object-contain" /> : <span className="grid size-full place-items-center font-semibold">{talent.type}</span>}
|
||||
</div>
|
||||
<Badge variant="secondary">{talent.level ? `Lv.${talent.level}+` : "—"}</Badge>
|
||||
<p className="text-sm font-medium">{talentName}</p>
|
||||
<p className="text-xs font-medium sm:text-sm">{talentName}</p>
|
||||
</div>
|
||||
{index < talents.length - 1 ? (
|
||||
<span className="mt-6 text-3xl font-semibold" aria-label={talent.relationToNext === "gte" ? "มากกว่าหรือเท่ากับ" : "มากกว่า"}>
|
||||
<span className="mt-4 text-xl font-semibold sm:mt-6 sm:text-3xl" aria-label={talent.relationToNext === "gte" ? "มากกว่าหรือเท่ากับ" : "มากกว่า"}>
|
||||
{talentRelationSymbol(talent.relationToNext)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -443,7 +451,7 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {
|
||||
{guide.constellations.map((row) => (
|
||||
<Card key={row.level}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl text-white">
|
||||
<CardTitle className={cn("text-3xl text-white", row.isGreat && "text-warning")}>
|
||||
{metric(row.cumulativePercent)}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base font-medium text-white">
|
||||
|
||||
@@ -618,6 +618,7 @@ export const constellationRecommendations = guidesSchema.table(
|
||||
level: integer("level").notNull(),
|
||||
detail: text("detail").notNull(),
|
||||
cumulativePercent: numeric("cumulative_percent", { precision: 8, scale: 2 }).notNull(),
|
||||
isGreat: boolean("is_great").default(false).notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.guideId, table.level] })],
|
||||
);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "guides"."constellation_recommendation" ADD COLUMN "is_great" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,13 @@
|
||||
"when": 1788086585687,
|
||||
"tag": "0007_catalog_talents_fixed_artifact_groups",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1788090159661,
|
||||
"tag": "0008_plain_gamora",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -227,4 +227,72 @@ export async function getPublicWeaponExport(guideId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicArtifactExport(guideId: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides, character: catalogCharacters })
|
||||
.from(guides)
|
||||
.innerJoin(
|
||||
guideSections,
|
||||
and(
|
||||
eq(guideSections.guideId, guides.id),
|
||||
eq(guideSections.slug, "artifact"),
|
||||
eq(guideSections.enabled, true),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
catalogCharacters,
|
||||
and(
|
||||
eq(catalogCharacters.version, guides.catalogVersion),
|
||||
eq(catalogCharacters.key, guides.characterKey),
|
||||
),
|
||||
)
|
||||
.where(and(eq(guides.id, guideId), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
|
||||
const [groups, profile] = await Promise.all([
|
||||
getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, guideId)).orderBy(asc(artifactGroups.sortOrder)),
|
||||
getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)).limit(1),
|
||||
]);
|
||||
const recommendations = groups.length
|
||||
? await getDb()
|
||||
.select({ recommendation: artifactRecommendations, artifact: catalogArtifacts })
|
||||
.from(artifactRecommendations)
|
||||
.innerJoin(
|
||||
catalogArtifacts,
|
||||
and(
|
||||
eq(catalogArtifacts.version, artifactRecommendations.catalogVersion),
|
||||
eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey),
|
||||
),
|
||||
)
|
||||
.where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id)))
|
||||
.orderBy(asc(artifactRecommendations.sortOrder))
|
||||
: [];
|
||||
const secondKeys = recommendations
|
||||
.map(({ recommendation }) => recommendation.secondArtifactKey)
|
||||
.filter((key): key is string => Boolean(key));
|
||||
const secondArtifacts = secondKeys.length
|
||||
? await getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, secondKeys)))
|
||||
: [];
|
||||
|
||||
return {
|
||||
guideName: row.guide.name,
|
||||
character: catalogCharacterWithUrls(row.character),
|
||||
groups: groups.map((group) => ({
|
||||
...group,
|
||||
rows: recommendations
|
||||
.filter(({ recommendation }) => recommendation.groupId === group.id)
|
||||
.map(({ recommendation, artifact }) => {
|
||||
const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey);
|
||||
return {
|
||||
...recommendation,
|
||||
first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) },
|
||||
second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
profile: profile[0] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export type PublicGuide = NonNullable<Awaited<ReturnType<typeof getPublicGuide>>>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { calculateTeamMetrics } from "@/lib/guides/calculations";
|
||||
import { artifactSectionSchema, createGuideSchema, teamSectionSchema, weaponSectionSchema } from "@/lib/guides/schemas";
|
||||
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, teamSectionSchema, weaponSectionSchema } from "@/lib/guides/schemas";
|
||||
|
||||
describe("structured guide validation", () => {
|
||||
const artifactGroups = [
|
||||
@@ -42,6 +42,12 @@ describe("structured guide validation", () => {
|
||||
expect(artifactSectionSchema.safeParse({ ...base, talents: [{ type: "E", priority: 1, relationToNext: "equal" }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("persists whether a constellation percentage is marked as great", () => {
|
||||
const row = { level: 1, detail: "Test constellation", cumulativePercent: 120, isGreat: true };
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, rows: [row] }).success).toBe(true);
|
||||
expect(constellationSectionSchema.safeParse({ expectedVersion: 1, rows: [{ ...row, isGreat: "yes" }] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires four unique team members and non-zero damage", () => {
|
||||
const member = (characterKey: string) => ({ characterKey, weaponKey: null, artifactKey: null, constellation: 0, refinement: 1, damage: 100 });
|
||||
const base = { expectedVersion: 1, teams: [{ title: "Test", rotationSeconds: 20, combo: "E > Q", members: ["1", "2", "3", "4"].map(member) }] };
|
||||
|
||||
@@ -78,6 +78,7 @@ export const constellationSectionSchema = z.strictObject({
|
||||
level: z.number().int().min(1).max(6),
|
||||
detail: z.string().trim().min(1).max(2_000),
|
||||
cumulativePercent: percent,
|
||||
isGreat: z.boolean(),
|
||||
})).max(6),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user