From 273baa55d8003c0ed4b678db0f932f434faaf0e0 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sun, 30 Aug 2026 10:52:37 +0000 Subject: [PATCH] feat : big update --- Dockerfile | 1 + app/admin/[character]/[page]/page.tsx | 3 +- .../guides/[guideId]/weapons/image/route.tsx | 136 + app/api/teams/[teamId]/image/route.tsx | 16 +- components/admin/guide-section-editor.tsx | 18 +- components/public/element-icon.tsx | 24 + components/public/structured-guide.tsx | 100 +- db/schema.ts | 3 +- ..._catalog_talents_fixed_artifact_groups.sql | 30 + drizzle/meta/0007_snapshot.json | 3484 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/catalog/lunaris.test.ts | 21 + lib/catalog/lunaris.ts | 38 +- lib/catalog/sync.ts | 80 +- lib/catalog/travelers.test.ts | 27 + lib/catalog/travelers.ts | 13 + lib/guides/export-image.test.ts | 17 + lib/guides/export-image.ts | 12 + lib/guides/mutations.ts | 4 + lib/guides/queries.ts | 44 +- lib/guides/schemas.test.ts | 13 +- lib/guides/schemas.ts | 21 +- 22 files changed, 4025 insertions(+), 87 deletions(-) create mode 100644 app/api/guides/[guideId]/weapons/image/route.tsx create mode 100644 components/public/element-icon.tsx create mode 100644 drizzle/0007_catalog_talents_fixed_artifact_groups.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 lib/catalog/travelers.test.ts create mode 100644 lib/catalog/travelers.ts create mode 100644 lib/guides/export-image.test.ts create mode 100644 lib/guides/export-image.ts diff --git a/Dockerfile b/Dockerfile index 13c6463..ab01513 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,7 @@ COPY --from=next-builder --chown=bun:bun /app/.next/standalone ./ COPY --from=next-builder --chown=bun:bun /app/.next/static ./.next/static COPY --from=next-builder --chown=bun:bun /app/public ./public COPY --from=worker-builder --chown=bun:bun /app/dist/outbox-worker.js ./worker/outbox-worker.js +COPY --from=worker-builder --chown=bun:bun /app/lib/catalog/assets ./lib/catalog/assets USER 1000:1000 EXPOSE 3000 CMD ["bun", "server.js"] diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 0059ef0..185f5f1 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -21,5 +21,6 @@ export default async function StructuredEditorPage({ params }: { params: Promise if (!guide) notFound(); const [catalog, content] = await Promise.all([getActiveCatalog(), getPublicGuide(guide.slug)]); if (!catalog || !content) notFound(); - return

{guide.name}

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

item.key === guide.characterKey)?.constellations ?? []} 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 })) })) }} />
; + 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 })) })) }} />
; } diff --git a/app/api/guides/[guideId]/weapons/image/route.tsx b/app/api/guides/[guideId]/weapons/image/route.tsx new file mode 100644 index 0000000..1449599 --- /dev/null +++ b/app/api/guides/[guideId]/weapons/image/route.tsx @@ -0,0 +1,136 @@ +/* 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 { getPublicWeaponExport } from "@/lib/guides/queries"; + +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 | null): string { + return value === null + ? "—" + : `${Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 })}%`; +} + +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(120, 120, { fit: "contain" }) + .png() + .toBuffer(); + return `data:image/png;base64,${png.toString("base64")}`; + } catch { + return null; + } +} + +export async function GET( + _request: Request, + context: { params: Promise<{ guideId: string }> }, +) { + const { guideId } = await context.params; + const guide = await getPublicWeaponExport(guideId); + if (!guide) return new Response("Guide not found", { status: 404 }); + + const [fonts, weaponImages] = await Promise.all([ + anuphanFonts, + Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))), + ]); + const height = Math.max(360, 190 + guide.weapons.length * 126 + Math.max(0, guide.weapons.length - 1) * 14); + + return new ImageResponse( +
+
+
{guide.guideName}
+
Weapons
+
+ {guide.weapons.map((row, index) => ( +
+
+ {weaponImages[index] ? ( + + ) : ( +
{index + 1}
+ )} +
+ {index + 1}. {row.weapon.name} R{row.refinement} +
+
+
+
+
{metric(row.overallPercent)}
+
ดาเมจทั้งทีม
+
+
+
{metric(row.personalPercent)}
+
ดาเมจส่วนตัว
+
+
+
+ ))} +
, + { + width: 1000, + height, + fonts: fonts.length ? fonts : undefined, + headers: { + "Cache-Control": "no-store", + "Content-Disposition": imageContentDisposition(`${guide.guideName} Weapons`, "weapons"), + "Content-Type": "image/png", + }, + }, + ); +} diff --git a/app/api/teams/[teamId]/image/route.tsx b/app/api/teams/[teamId]/image/route.tsx index 0842c52..e5e62bb 100644 --- a/app/api/teams/[teamId]/image/route.tsx +++ b/app/api/teams/[teamId]/image/route.tsx @@ -3,6 +3,7 @@ import { ImageResponse } from "next/og"; import sharp from "sharp"; import { rarityGradient } from "@/lib/catalog/rarity"; +import { imageContentDisposition } from "@/lib/guides/export-image"; import { getPublicTeamExport } from "@/lib/guides/queries"; const anuphanFonts = Promise.all( @@ -29,19 +30,6 @@ function amount(value: number): string { return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value); } -function contentDisposition(teamName: string): string { - const displayName = teamName.trim() || "team"; - const fallback = displayName - .normalize("NFKD") - .replace(/[^a-z0-9]+/giu, "-") - .replace(/^-|-$/gu, "") || "team"; - const encoded = encodeURIComponent(`${displayName}.png`).replace( - /['()*]/gu, - (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, - ); - return `attachment; filename="${fallback}.png"; filename*=UTF-8''${encoded}`; -} - async function pngDataUrl(url: string): Promise { try { const response = await fetch(url); @@ -186,7 +174,7 @@ export async function GET( fonts: fonts.length ? fonts : undefined, headers: { "Cache-Control": "no-store", - "Content-Disposition": contentDisposition(team.title), + "Content-Disposition": imageContentDisposition(team.title, "team"), "Content-Type": "image/png", }, }, diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index b38933f..71d9e12 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -51,6 +51,7 @@ type ArtifactValue = { id: string; name: string; rows: Array<{ firstArtifactKey: type ArtifactProfile = { sands: string[]; goblet: string[]; circlet: string[]; substats: string[]; targets: Array<{ stat: string; value: string }>; talents: Array<{ type: "NA" | "E" | "Q"; priority: number; level?: string }>; note: string | null } | null; type ConstellationValue = { level: number; detail: string; cumulativePercent: string }; type CatalogConstellation = { level: number; name: string; description: string; iconUrl: string | null }; +type CatalogTalent = { type: "NA" | "E" | "Q"; name: string; description: 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 }> }; export interface GuideSectionEditorProps { @@ -60,6 +61,7 @@ export interface GuideSectionEditorProps { artifacts: CatalogItem[]; characters: CatalogItem[]; catalogConstellations: CatalogConstellation[]; + catalogTalents: CatalogTalent[]; initial: { weapons: WeaponValue[]; artifactGroups: ArtifactValue[]; @@ -143,9 +145,12 @@ function WeaponEditor({ guide, weapons, characters, initial }: EditorBase) { ); } -function ArtifactEditor({ guide, artifacts, initial }: EditorBase) { +function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBase) { const profile = initial.artifactProfile; - const [groups, setGroups] = useState(initial.artifactGroups.map((group) => ({ name: group.name, rows: group.rows.map((row) => ({ ...row })) }))); + const [groups, setGroups] = useState(() => (["Late Game", "Early Game"] as const).map((name, index) => ({ + name, + rows: initial.artifactGroups[index]?.rows.map((row) => ({ ...row })) ?? [], + }))); const [sands, setSands] = useState(profile?.sands ?? []); const [goblet, setGoblet] = useState(profile?.goblet ?? []); const [circlet, setCirclet] = useState(profile?.circlet ?? []); @@ -155,7 +160,7 @@ function ArtifactEditor({ guide, artifacts, initial }: EditorBase) { const defaults = DEFAULT_TARGET_STATS.map((stat) => existing.find((target) => target.stat === stat) ?? { stat, value: "" }); return [...defaults, ...existing.filter((target) => !DEFAULT_TARGET_STATS.includes(target.stat as (typeof DEFAULT_TARGET_STATS)[number]))]; }); - const [talents, setTalents] = useState(profile?.talents ?? (["NA", "E", "Q"] as const).map((type, priority) => ({ type, priority: priority + 1, level: "" }))); + const [talents, setTalents] = useState(profile?.talents.length ? profile.talents : (["NA", "E", "Q"] as const).map((type, priority) => ({ type, priority: priority + 1, level: "" }))); const [note, setNote] = useState(profile?.note ?? ""); const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent) })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null }; const autosave = useAutosave({ value, valid: artifactSectionSchema.safeParse({ ...value, expectedVersion: guide.version }).success, initialVersion: guide.version, persist: (next, expectedVersion) => saveArtifactSection(guide.id, { ...next, expectedVersion }) }); @@ -163,15 +168,14 @@ function ArtifactEditor({ guide, artifacts, initial }: EditorBase) { return (
- Artifact tier listสร้างกลุ่มและค้นหาชุด Artifact ด้วยชื่อ ระบบจะบันทึกอัตโนมัติ + Artifact tier listจัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ - {groups.map((group, groupIndex) => setGroups(groups.map((item, index) => index === groupIndex ? { ...item, name: event.target.value } : item))} />{group.rows.map((row, rowIndex) =>
setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} /> setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} /> setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} />
)}
)} + {groups.map((group, groupIndex) => {group.name}{group.rows.map((row, rowIndex) =>
setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, firstArtifactKey } : candidate) } : item))} /> setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, secondArtifactKey: secondArtifactKey || null } : candidate) } : item))} /> setGroups(groups.map((item, index) => index === groupIndex ? { ...item, rows: item.rows.map((candidate, inner) => inner === rowIndex ? { ...candidate, percent: event.target.value } : candidate) } : item))} />
)}
)}
-
Recommended Statsเลือกค่าจากรายการแทนปุ่ม และเลือก Main Stat ได้สูงสุด 2 ค่าต่อชิ้น -
Target Stats{targets.map((target, index) =>
setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, stat: event.target.value } : item))} /> setTargets(targets.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
)}
Talent Priority{orderedTalents.map((talent, index) =>
{index + 1}. {talent.type} setTalents(talents.map((item) => item.type === talent.type ? { ...item, level: event.target.value } : item))} /> setTalents(moveItem(orderedTalents, from, to).map((item, priority) => ({ ...item, priority: priority + 1 })))} />
)}
Note (optional)