diff --git a/.env.example b/.env.example index 1dab86b..4f07cc7 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ DATABASE_POOL_SIZE=10 # Better Auth email/password administrator login BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes +# Separate trusted browser origins with commas for local development or proxies. +BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 # Separate multiple administrator addresses with commas. ADMIN_EMAIL=admin@example.com diff --git a/app/[character]/[page]/page.tsx b/app/[character]/[page]/page.tsx index 0169a10..a9cab81 100644 --- a/app/[character]/[page]/page.tsx +++ b/app/[character]/[page]/page.tsx @@ -3,21 +3,21 @@ import { notFound } from "next/navigation"; import { connection } from "next/server"; import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide"; -import { getPublicGuide } from "@/lib/guides/queries"; +import { getCachedPublicGuide } from "@/lib/cache/public"; type Params = Promise<{ character: string; page: string }>; export async function generateMetadata({ params }: { params: Params }): Promise { await connection(); const { character, page } = await params; - const guide = await getPublicGuide(character); + const guide = await getCachedPublicGuide(character); return guide ? { title: `${page} — ${guide.name} Build Guide`, description: guide.overview } : { title: "ไม่พบ Guide" }; } export default async function GuideSectionPage({ params }: { params: Params }) { await connection(); const { character, page } = await params; - const guide = await getPublicGuide(character); + const guide = await getCachedPublicGuide(character); if (!guide || !publicSections(guide).some((section) => section.slug === page)) notFound(); return ; } diff --git a/app/[character]/page.tsx b/app/[character]/page.tsx index 26b02ea..fa11f6c 100644 --- a/app/[character]/page.tsx +++ b/app/[character]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from "next/navigation"; import { connection } from "next/server"; import { StructuredGuideShell } from "@/components/public/structured-guide"; -import { getPublicGuide } from "@/lib/guides/queries"; +import { getCachedPublicGuide } from "@/lib/cache/public"; type Params = Promise<{ character: string }>; @@ -13,7 +13,7 @@ export async function generateMetadata({ params: Params; }): Promise { await connection(); - const guide = await getPublicGuide((await params).character); + const guide = await getCachedPublicGuide((await params).character); return guide ? { title: `${guide.name} Build Guide`, description: guide.overview } : { title: "ไม่พบ Guide" }; @@ -25,7 +25,7 @@ export default async function GuideOverviewPage({ params: Params; }) { await connection(); - const guide = await getPublicGuide((await params).character); + const guide = await getCachedPublicGuide((await params).character); if (!guide) notFound(); return ; } diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index ed77ccf..b4d7f66 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, note: row.note })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ id: team.id, 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, note: row.note })) })), artifactProfile: content.artifactProfile, constellations: content.constellations, teams: content.teams.map((team) => ({ id: team.id, 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, ratio: row.ratio === "1:1" ? "1:1" : "16:9", heading: row.heading, body: row.body, percent: row.percent, imageUrl: row.imageUrl })) })) }} />
; } diff --git a/app/admin/[character]/page.tsx b/app/admin/[character]/page.tsx index d0dd342..6767cd1 100644 --- a/app/admin/[character]/page.tsx +++ b/app/admin/[character]/page.tsx @@ -1,22 +1,23 @@ import Image from "next/image"; import Link from "next/link"; -import { ArrowLeftIcon, BoxesIcon, GemIcon, SparklesIcon, SwordsIcon, UsersIcon } from "lucide-react"; +import { ArrowLeftIcon, ArrowUpRightIcon } from "lucide-react"; import { notFound, redirect } from "next/navigation"; import { connection } from "next/server"; import { Button } from "@/components/ui/button"; -import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardAction, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AdminHeader } from "@/components/admin/admin-header"; import { GuideOverviewForm } from "@/components/admin/guide-overview-form"; import { GuideSectionNav } from "@/components/admin/guide-section-nav"; import { getAdminSession } from "@/lib/auth/server"; import { getGuideByCharacterKey } from "@/lib/guides/queries"; +import { navigationIconPath } from "@/lib/navigation"; const sections = [ - { slug: "weapon", title: "Weapons", description: "จัดอันดับอาวุธ Refinement และเปอร์เซ็นต์", icon: SwordsIcon }, - { slug: "artifact", title: "Artifacts & Stats", description: "ชุด Artifact, Main/Sub Stats และ Talent", icon: GemIcon }, - { slug: "constellations", title: "Constellations", description: "รายละเอียด C1–C6 และเปอร์เซ็นต์สะสม", icon: SparklesIcon }, - { slug: "team", title: "Team Calc", description: "ตัวละคร 4 ตัว Damage share, DPS และ Combo", icon: UsersIcon }, + { slug: "weapon", title: "Weapons", description: "จัดอันดับอาวุธ Refinement และเปอร์เซ็นต์" }, + { slug: "artifact", title: "Artifacts & Stats", description: "ชุด Artifact, Main/Sub Stats และ Talent" }, + { slug: "constellations", title: "Constellations", description: "รายละเอียด C1–C6 และเปอร์เซ็นต์สะสม" }, + { slug: "team", title: "Team Calc", description: "ตัวละคร 4 ตัว Damage share, DPS และ Combo" }, ]; export default async function GuideOverviewPage({ params }: { params: Promise<{ character: string }> }) { @@ -25,5 +26,5 @@ export default async function GuideOverviewPage({ params }: { params: Promise<{ const { character } = await params; const guide = await getGuideByCharacterKey(character); if (!guide) notFound(); - return
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description, icon: Icon }) => {title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; + return
{guide.name}

เลือกส่วนที่ต้องการแก้ไข

{guide.name}

{sections.map(({ slug, title, description }) => {navigationIconPath(slug) ? : null}{title}{description})}Extra sectionsส่วนเสริมแบบภาพ หัวข้อ ข้อความสั้น และเปอร์เซ็นต์
; } diff --git a/app/api/guides/[guideId]/artifacts/image/route.tsx b/app/api/guides/[guideId]/artifacts/image/route.tsx index d1df9bf..d696eb3 100644 --- a/app/api/guides/[guideId]/artifacts/image/route.tsx +++ b/app/api/guides/[guideId]/artifacts/image/route.tsx @@ -5,10 +5,11 @@ import { ImageResponse } from "next/og"; import sharp from "sharp"; import { rarityGradient } from "@/lib/catalog/rarity"; +import { artifactTierCount } from "@/lib/guides/artifacts"; +import { getCachedPublicArtifactExport } from "@/lib/cache/public"; import { imageContentDisposition } from "@/lib/guides/export-image"; -import { getPublicArtifactExport } from "@/lib/guides/queries"; import { normalizeTargetStat, statPriorityColor, targetStatIconName, targetStatOptions } from "@/lib/guides/stats"; -import { normalizeTalentPriorities } from "@/lib/guides/talents"; +import { normalizeTalentPriorities, talentTypeLabel } from "@/lib/guides/talents"; const anuphanFonts = Promise.all( [ @@ -58,7 +59,7 @@ async function pngDataUrl(url: string): Promise { async function localPngDataUrl(fileName: string, size: number | null = 96, white = false): Promise { let image = sharp(join(process.cwd(), "public", "icon", fileName)); - if (white) image = image.negate(); + if (white) image = image.negate({ alpha: false }); const png = await (size === null ? image : image.resize(size, size, { fit: "contain" })) .png() .toBuffer(); @@ -70,11 +71,11 @@ export async function GET( context: RouteContext<"/api/guides/[guideId]/artifacts/image">, ) { const { guideId } = await context.params; - const guide = await getPublicArtifactExport(guideId); + const guide = await getCachedPublicArtifactExport(guideId); if (!guide) return new Response("Guide not found", { status: 404 }); const talents = normalizeTalentPriorities(guide.profile?.talents ?? []); - const [fonts, groupAssets, statIconAssets, talentAssets, targetStatAssets, watermark] = await Promise.all([ + const [fonts, groupAssets, statIconAssets, talentAssets, targetStatAssets] = await Promise.all([ anuphanFonts, Promise.all( guide.groups.map((group) => @@ -96,7 +97,6 @@ export async function GET( return catalogTalent?.iconUrl ? pngDataUrl(catalogTalent.iconUrl) : null; })), Promise.all(targetStatOptions.map((stat) => localPngDataUrl(`stats/${targetStatIconName(stat)}.webp`, 96, true))), - localPngDataUrl("Watermark.png", null), ]); const profile = guide.profile; @@ -125,7 +125,6 @@ export async function GET( position: "relative", }} > -
@@ -155,7 +154,7 @@ export async function GET(
{group.rows.map((row, rowIndex) => ( -
+
{groupAssets[groupIndex][rowIndex].first ? (
@@ -170,11 +169,12 @@ export async function GET(
- {rowIndex + 1}. {row.first.name}{row.second ? ` + ${row.second.name}` : ""} + {artifactTierCount(row.second?.name ?? null)}{" "} + {row.first.name}{row.second ? ` + ${row.second.name}` : ""}
{row.note ?
{row.note}
: null}
-
+
{metric(row.percent)}
@@ -240,7 +240,10 @@ export async function GET( {talents.map((talent, index) => (
{talentAssets[index] ? : null} - {talent.type}{talent.level ? ` Lv.${talent.level}+` : ""} + + {talent.type}{talent.level ? ` Lv.${talent.level}+` : ""} + {talentTypeLabel(talent.type)} + {index < talents.length - 1 ? {talent.relationToNext === "gte" ? ">=" : ">"} : null}
))} diff --git a/app/api/guides/[guideId]/weapons/image/route.tsx b/app/api/guides/[guideId]/weapons/image/route.tsx index 4c1787c..4a4dae9 100644 --- a/app/api/guides/[guideId]/weapons/image/route.tsx +++ b/app/api/guides/[guideId]/weapons/image/route.tsx @@ -1,12 +1,10 @@ /* eslint-disable @next/next/no-img-element */ -import { join } from "node:path"; - import { ImageResponse } from "next/og"; import sharp from "sharp"; import { rarityGradient, rarityTextColor } from "@/lib/catalog/rarity"; +import { getCachedPublicWeaponExport } from "@/lib/cache/public"; import { imageContentDisposition } from "@/lib/guides/export-image"; -import { getPublicWeaponExport } from "@/lib/guides/queries"; const anuphanFonts = Promise.all( [ @@ -46,26 +44,17 @@ async function pngDataUrl(url: string): Promise { } } -async function localPngDataUrl(fileName: string, size: number | null = 96): Promise { - const image = sharp(join(process.cwd(), "public", "icon", fileName)); - const png = await (size === null ? image : image.resize(size, size, { fit: "contain" })) - .png() - .toBuffer(); - return `data:image/png;base64,${png.toString("base64")}`; -} - export async function GET( _request: Request, context: { params: Promise<{ guideId: string }> }, ) { const { guideId } = await context.params; - const guide = await getPublicWeaponExport(guideId); + const guide = await getCachedPublicWeaponExport(guideId); if (!guide) return new Response("Guide not found", { status: 404 }); - const [fonts, weaponImages, watermark] = await Promise.all([ + const [fonts, weaponImages] = await Promise.all([ anuphanFonts, Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))), - localPngDataUrl("Watermark.png", null), ]); const weaponHeights = guide.weapons.map((row) => Math.max(126, 126 + (row.note ? Math.ceil(row.note.length / 52) * 22 : 0))); const height = Math.max(360, 190 + weaponHeights.reduce((sum, rowHeight) => sum + rowHeight, 0) + Math.max(0, guide.weapons.length - 1) * 14); @@ -85,7 +74,6 @@ export async function GET( position: "relative", }} > -
อาวุธที่แนะนำ
@@ -104,26 +92,28 @@ export async function GET( >
- {weaponImages[index] ? ( - - ) : ( -
{index + 1}
- )} -
-
- {index + 1}. {row.weapon.name} R{row.refinement} +
+ {weaponImages[index] ? ( + + ) : ( +
{index + 1}
+ )} +
+
+ {index + 1}. {row.weapon.name} R{row.refinement} +
+ {row.note ?
{row.note}
: null}
- {row.note ?
{row.note}
: null}
diff --git a/app/api/teams/[teamId]/image/route.tsx b/app/api/teams/[teamId]/image/route.tsx index ae5bac8..a10c895 100644 --- a/app/api/teams/[teamId]/image/route.tsx +++ b/app/api/teams/[teamId]/image/route.tsx @@ -1,11 +1,11 @@ /* eslint-disable @next/next/no-img-element */ import { ImageResponse } from "next/og"; -import { join } from "node:path"; import sharp from "sharp"; import { rarityGradient } from "@/lib/catalog/rarity"; +import { isCustomTravelerKey } from "@/lib/catalog/travelers"; +import { getCachedPublicTeamExport } from "@/lib/cache/public"; import { imageContentDisposition } from "@/lib/guides/export-image"; -import { getPublicTeamExport } from "@/lib/guides/queries"; const anuphanFonts = Promise.all( [ @@ -45,20 +45,12 @@ async function pngDataUrl(url: string): Promise { } } -async function localPngDataUrl(fileName: string, size: number | null = 96): Promise { - const image = sharp(join(process.cwd(), "public", "icon", fileName)); - const png = await (size === null ? image : image.resize(size, size, { fit: "contain" })) - .png() - .toBuffer(); - return `data:image/png;base64,${png.toString("base64")}`; -} - export async function GET( _request: Request, context: RouteContext<"/api/teams/[teamId]/image">, ) { const { teamId } = await context.params; - const team = await getPublicTeamExport(teamId); + const team = await getCachedPublicTeamExport(teamId); if (!team) return new Response("Team not found", { status: 404 }); const total = team.members.reduce( @@ -67,9 +59,8 @@ export async function GET( ); const seconds = Number(team.rotationSeconds); const dps = seconds > 0 ? total / seconds : 0; - const [fonts, watermark, memberAssets] = await Promise.all([ + const [fonts, memberAssets] = await Promise.all([ anuphanFonts, - localPngDataUrl("Watermark.png", null), Promise.all( team.members.map(async (member) => { const [character, weapon, artifact] = await Promise.all([ @@ -98,7 +89,6 @@ export async function GET( position: "relative", }} > -
@@ -121,10 +111,10 @@ export async function GET( alt="" width={78} height={78} - style={{ borderRadius: 16, border: "2px solid #78cbc4", objectFit: "cover" }} + style={{ borderRadius: 16, border: "2px solid #78cbc4", background: rarityGradient(isCustomTravelerKey(member.character.key) ? 5 : member.character.rarity), objectFit: "cover" }} /> ) : ( -
+
{member.character.name.slice(0, 1)}
)} @@ -132,7 +122,7 @@ export async function GET( {member.weapon && memberAssets[index].weapon ? (
-
+
R{member.refinement}
@@ -170,15 +160,15 @@ export async function GET( })}
-
+
+
Team Rotation
+
{team.combo}
+
DPS: {amount(dps)} | DPR: {amount(total)}
-
- {team.combo} -
By YT เกนชินไม่ใช่เกมมือถือ
diff --git a/app/page.tsx b/app/page.tsx index 5489a45..4cf926c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,11 +3,11 @@ import { connection } from "next/server"; import { LiveRefresh } from "@/components/live-refresh"; import { StructuredDirectory } from "@/components/public/structured-directory"; import { SiteHeader } from "@/components/public/site-header"; -import { listGuideCards } from "@/lib/guides/queries"; +import { getCachedPublicGuideCards } from "@/lib/cache/public"; export default async function HomePage() { await connection(); - const guides = await listGuideCards(); + const guides = await getCachedPublicGuideCards(); return (
diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index fafb02b..f2db032 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -13,6 +13,7 @@ import { TimerIcon, Trash2Icon, UserRoundIcon, + XIcon, } from "lucide-react"; import { useState, type ReactNode } from "react"; import { @@ -86,6 +87,7 @@ import { import { normalizeTargetStat, targetStatDefaults, + targetStatIconName, targetStatOptions, } from "@/lib/guides/stats"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; @@ -122,24 +124,29 @@ export interface GuideSectionEditorProps { artifactProfile: ArtifactProfile; constellations: ConstellationValue[]; teams: TeamValue[]; - extras: Array<{ title: string; rows: Array<{ mediaId: string | null; heading: string; body: string; percent: string | null; imageUrl: string | null }> }>; + extras: Array<{ title: string; rows: Array<{ mediaId: string | null; ratio: "1:1" | "16:9"; heading: string; body: string; percent: string | null; imageUrl: string | null }> }>; }; } const SELECTED_TOGGLE_CLASS = "aria-pressed:border-primary aria-pressed:ring-2 aria-pressed:ring-primary/30"; -function CatalogPicker({ items, value, onChange, placeholder, rarityBackground = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; rarityBackground?: boolean }) { +function CatalogPicker({ items, value, onChange, placeholder, rarityBackground = false, showSelectedImage = false }: { items: CatalogItem[]; value: string; onChange: (value: string) => void; placeholder: string; rarityBackground?: boolean; showSelectedImage?: boolean }) { const selected = items.find((item) => item.key === value) ?? null; return ( - onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} autoHighlight> - - - ไม่พบรายการ - - {(item) =>
{item.name}
} -
-
-
+
+ {showSelectedImage && selected ?
: null} +
+ onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} autoHighlight> + + + ไม่พบรายการ + + {(item) =>
{item.name}
} +
+
+
+
+
); } @@ -148,14 +155,26 @@ function StatSelect({ label, options, value, max, onChange }: { label: string; o {label} เลือกได้สูงสุด {max} ค่า ); } +function StatIcons({ values }: { values: readonly string[] }) { + const iconNames = values.flatMap((value) => { + const targetIcon = targetStatIconName(value); + if (targetIcon) return [targetIcon]; + if (value === "CRIT Rate%/CRIT DMG%") return ["critrate", "critdmg"]; + const base = value.replace(/%$/, "").toLowerCase(); + return ["hp", "atk", "def", "em", "er"].includes(base) ? [base] : []; + }); + if (!iconNames.length) return null; + return ; +} + function TalentLevelPicker({ value, onChange }: { value?: string; onChange: (value?: string) => void }) { const selected = talentLevelOptions.find((level) => level === value) ?? null; return ( @@ -348,7 +367,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas Artifact tier listจัดชุด Artifact ในกลุ่ม Late Game และ Early Game ระบบจะบันทึกอัตโนมัติ - {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))} />
Note (optional)