From 6003f408bd10c4ec4cfd1c0b219cf7796dc723a5 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 30 Aug 2026 11:51:01 +0000 Subject: [PATCH] fix : fix --- app/admin/[character]/[page]/page.tsx | 2 +- .../[guideId]/artifacts/image/route.tsx | 235 ++ components/admin/guide-section-editor.tsx | 14 +- components/admin/guide-section-nav.tsx | 2 +- components/public/structured-guide.tsx | 52 +- db/schema.ts | 1 + drizzle/0008_plain_gamora.sql | 1 + drizzle/meta/0008_snapshot.json | 3491 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/guides/queries.ts | 68 + lib/guides/schemas.test.ts | 8 +- lib/guides/schemas.ts | 1 + 12 files changed, 3852 insertions(+), 30 deletions(-) create mode 100644 app/api/guides/[guideId]/artifacts/image/route.tsx create mode 100644 drizzle/0008_plain_gamora.sql create mode 100644 drizzle/meta/0008_snapshot.json diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 185f5f1..f42f0e5 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -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

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

({ 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 })) })) }} />
; + return

{guide.name}

{guide.sections.find((section) => section.slug === page)?.title ?? page}

({ 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 })) })) }} />
; } diff --git a/app/api/guides/[guideId]/artifacts/image/route.tsx b/app/api/guides/[guideId]/artifacts/image/route.tsx new file mode 100644 index 0000000..3144fa5 --- /dev/null +++ b/app/api/guides/[guideId]/artifacts/image/route.tsx @@ -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/anuphan@5.3.0/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 { + 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( +
+
+
+ อาร์ติแฟกต์และสเตตัสที่แนะนำ +
+
+ Artifacts & Stats +
+
+ +
+
+ {guide.groups.map((group, groupIndex) => ( +
+
+ {group.name} +
+
+ {group.rows.map((row, rowIndex) => ( +
+
+ {groupAssets[groupIndex][rowIndex].first ? ( +
+ +
+ ) : null} + {row.second && groupAssets[groupIndex][rowIndex].second ? ( +
+ +
+ ) : null} +
+
+ {rowIndex + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""} +
+
+ {metric(row.percent)} +
+
+ ))} +
+
+ ))} +
+ + {profile ? ( +
+
+
+ Artifact Stats +
+
+ {[ + { label: "Sands", values: profile.sands }, + { label: "Goblet", values: profile.goblet }, + { label: "Circlet", values: profile.circlet }, + ].map((stat) => ( +
+
{stat.label}
+
+
+ {stat.values.length ? stat.values.map(statLabel).join(" > ") : "—"} +
+
+ ))} +
+
+ + {profile.substats.length ? ( +
+ Stat Priority: {profile.substats.map(statLabel).join(" > ")} +
+ ) : null} + + {detailHeight > 60 ? ( +
+
รายละเอียดเพิ่มเติม
+ {profile.targets.length ? ( +
+
Target Stats
+ {profile.targets.map((target) => ( +
+ {target.stat}{target.value} +
+ ))} +
+ ) : null} + {talents.length ? ( +
+
ลำดับการอัพพรสวรรค์
+
+ {talents.map((talent, index) => ( +
+ {talent.type}{talent.level ? ` Lv.${talent.level}+` : ""} + {index < talents.length - 1 ? {talent.relationToNext === "gte" ? ">=" : ">"} : null} +
+ ))} +
+
+ ) : null} + {profile.note ? ( +
+
โน็ต
+
{profile.note}
+
+ ) : null} +
+ ) : null} +
+ ) : null} +
+
, + { + width: 1200, + height, + fonts: fonts.length ? fonts : undefined, + headers: { + "Cache-Control": "no-store", + "Content-Disposition": imageContentDisposition(`${guide.guideName} Artifacts`, "artifacts"), + "Content-Type": "image/png", + }, + }, + ); +} diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 9594b16..61760cb 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -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 ( Constellationsชื่อ รายละเอียด และไอคอนมาจาก Lunaris อัตโนมัติ กรอกเฉพาะเปอร์เซ็นต์เทียบกับ C0 = 100% - {rows.map((row, index) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} />)} + {rows.map((row, index) => {row.iconUrl ? : null}C{row.level} · {row.name}{row.description}Cumulative % setRows(rows.map((item, itemIndex) => itemIndex === index ? { ...item, cumulativePercent: event.target.value } : item))} /> 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">Great %)} diff --git a/components/admin/guide-section-nav.tsx b/components/admin/guide-section-nav.tsx index 6164944..08bd1d0 100644 --- a/components/admin/guide-section-nav.tsx +++ b/components/admin/guide-section-nav.tsx @@ -22,7 +22,7 @@ export function GuideSectionNav({ }) { return ( - + -
+
{guide.name}
-
+

{guide.name}

+ {guide.character.element ? ( + + {guide.character.elementIconUrl ? ( + + ) : null} + {guide.character.element} + + ) : null}
- {guide.character.element ? ( - - {guide.character.elementIconUrl ? ( - - ) : null} - {guide.character.element} - - ) : null}