diff --git a/app/api/guides/[guideId]/artifacts/image/route.tsx b/app/api/guides/[guideId]/artifacts/image/route.tsx index caa7968..c07e87c 100644 --- a/app/api/guides/[guideId]/artifacts/image/route.tsx +++ b/app/api/guides/[guideId]/artifacts/image/route.tsx @@ -7,6 +7,7 @@ import sharp from "sharp"; import { rarityGradient } from "@/lib/catalog/rarity"; 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"; const anuphanFonts = Promise.all( @@ -55,9 +56,9 @@ async function pngDataUrl(url: string): Promise { } } -async function localPngDataUrl(fileName: string): Promise { - const png = await sharp(join(process.cwd(), "public", "icon", fileName)) - .resize(96, 96, { fit: "contain" }) +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")}`; @@ -72,7 +73,7 @@ export async function GET( if (!guide) return new Response("Guide not found", { status: 404 }); const talents = normalizeTalentPriorities(guide.profile?.talents ?? []); - const [fonts, groupAssets, statIconAssets, talentAssets] = await Promise.all([ + const [fonts, groupAssets, statIconAssets, talentAssets, targetStatAssets, watermark] = await Promise.all([ anuphanFonts, Promise.all( guide.groups.map((group) => @@ -93,13 +94,15 @@ export async function GET( const catalogTalent = guide.character.talents.find((item) => item.type === talent.type); return catalogTalent?.iconUrl ? pngDataUrl(catalogTalent.iconUrl) : null; })), + Promise.all(targetStatOptions.map((stat) => localPngDataUrl(`stats/${targetStatIconName(stat)}.webp`))), + localPngDataUrl("Watermark.png", null), ]); const profile = guide.profile; const groupRowHeights = guide.groups.map((group) => group.rows.map((row) => Math.max(76, 76 + (row.note ? Math.ceil(row.note.length / 58) * 22 : 0)))); const leftHeight = guide.groups.reduce((sum, _group, groupIndex) => sum + 68 + groupRowHeights[groupIndex].reduce((groupSum, rowHeight) => groupSum + rowHeight, 0), 0) + Math.max(0, guide.groups.length - 1) * 16; const detailHeight = profile - ? 60 + profile.targets.length * 30 + (profile.talents.length ? 82 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0) + ? 60 + profile.targets.length * 40 + (profile.talents.length ? 82 : 0) + (profile.note ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) : 0) : 0; const rightHeight = profile ? 242 + (profile.substats.length ? 76 : 0) + (detailHeight > 60 ? detailHeight + 16 : 0) + 32 @@ -118,8 +121,11 @@ export async function GET( background: "#05060a", padding: "32px 36px", fontFamily: "Anuphan", + position: "relative", }} > + +
อาร์ติแฟกต์และสเตตัสที่แนะนำ @@ -148,7 +154,7 @@ export async function GET(
{group.rows.map((row, rowIndex) => ( -
+
{groupAssets[groupIndex][rowIndex].first ? (
@@ -193,8 +199,8 @@ export async function GET(
{stat.label}
-
- {stat.values.length ? stat.values.map(statLabel).join(" > ") : "—"} +
+ {stat.values.length ? stat.values.map((value, index) => {statLabel(value)}) : "—"}
))} @@ -202,8 +208,8 @@ export async function GET(
{profile.substats.length ? ( -
- Stat Priority: {profile.substats.map(statLabel).join(" > ")} +
+ Stat Priority: {profile.substats.map((stat, index) => {index ? " > " : ""}{statLabel(stat)})}
) : null} @@ -213,11 +219,14 @@ export async function GET( {profile.targets.length ? (
Target Stats
- {profile.targets.map((target) => ( -
- {target.stat}{target.value} + {profile.targets.map((target, index) => { + const iconIndex = targetStatOptions.findIndex((stat) => stat === normalizeTargetStat(target.stat)); + return ( +
+ {iconIndex >= 0 ? : null}{normalizeTargetStat(target.stat) ?? target.stat}{target.value}
- ))} + ); + })}
) : null} {talents.length ? ( @@ -245,6 +254,7 @@ export async function GET(
) : null}
+
, { width: 1200, diff --git a/app/api/guides/[guideId]/weapons/image/route.tsx b/app/api/guides/[guideId]/weapons/image/route.tsx index d92f937..4c1787c 100644 --- a/app/api/guides/[guideId]/weapons/image/route.tsx +++ b/app/api/guides/[guideId]/weapons/image/route.tsx @@ -1,4 +1,6 @@ /* eslint-disable @next/next/no-img-element */ +import { join } from "node:path"; + import { ImageResponse } from "next/og"; import sharp from "sharp"; @@ -44,6 +46,14 @@ 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 }> }, @@ -52,9 +62,10 @@ export async function GET( const guide = await getPublicWeaponExport(guideId); if (!guide) return new Response("Guide not found", { status: 404 }); - const [fonts, weaponImages] = await Promise.all([ + const [fonts, weaponImages, watermark] = 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); @@ -71,8 +82,11 @@ export async function GET( background: "#05060a", padding: "32px 36px", fontFamily: "Anuphan", + position: "relative", }} > + +
อาวุธที่แนะนำ
Weapons
@@ -128,6 +142,7 @@ export async function GET(
))} +
, { width: 1000, diff --git a/app/api/teams/[teamId]/image/route.tsx b/app/api/teams/[teamId]/image/route.tsx index e5e62bb..ae5bac8 100644 --- a/app/api/teams/[teamId]/image/route.tsx +++ b/app/api/teams/[teamId]/image/route.tsx @@ -1,5 +1,6 @@ /* 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"; @@ -44,6 +45,14 @@ 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">, @@ -58,8 +67,10 @@ export async function GET( ); const seconds = Number(team.rotationSeconds); const dps = seconds > 0 ? total / seconds : 0; - const fonts = await anuphanFonts; - const memberAssets = await Promise.all( + const [fonts, watermark, memberAssets] = await Promise.all([ + anuphanFonts, + localPngDataUrl("Watermark.png", null), + Promise.all( team.members.map(async (member) => { const [character, weapon, artifact] = await Promise.all([ pngDataUrl(member.character.imageUrl), @@ -68,7 +79,8 @@ export async function GET( ]); return { character, weapon, artifact }; }), - ); + ), + ]); return new ImageResponse(
+ +
{team.guideName} @@ -117,7 +132,7 @@ export async function GET( {member.weapon && memberAssets[index].weapon ? (
-
+
R{member.refinement}
@@ -167,6 +182,7 @@ export async function GET(
By YT เกนชินไม่ใช่เกมมือถือ
+
, { width: 550, diff --git a/app/globals.css b/app/globals.css index 8e4f11d..976f399 100644 --- a/app/globals.css +++ b/app/globals.css @@ -23,6 +23,9 @@ --color-success: var(--success); --color-warning: var(--warning); --color-info: var(--info); + --color-tier-gold: var(--tier-gold); + --color-tier-silver: var(--tier-silver); + --color-tier-copper: var(--tier-copper); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -73,6 +76,9 @@ --success: oklch(0.72 0.15 155); --warning: oklch(0.8 0.15 82); --info: oklch(0.72 0.12 235); + --tier-gold: oklch(0.8 0.15 82); + --tier-silver: oklch(0.82 0.02 260); + --tier-copper: oklch(0.65 0.12 55); --border: oklch(0.96 0.01 88 / 12%); --input: oklch(0.96 0.01 88 / 16%); --ring: oklch(0.79 0.14 78); diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 5d935f0..fafb02b 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -1,7 +1,19 @@ "use client"; import Image from "next/image"; -import { GripVerticalIcon, PlusIcon, SparklesIcon, Trash2Icon } from "lucide-react"; +import { + ChartNoAxesCombinedIcon, + GemIcon, + GripVerticalIcon, + ListOrderedIcon, + PlusIcon, + RefreshCcwIcon, + SparklesIcon, + SwordIcon, + TimerIcon, + Trash2Icon, + UserRoundIcon, +} from "lucide-react"; import { useState, type ReactNode } from "react"; import { closestCenter, @@ -31,6 +43,17 @@ import { import { AutosaveStatus } from "@/components/admin/autosave-status"; import { MediaUploader } from "@/components/admin/media-uploader"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { @@ -60,6 +83,11 @@ import { teamSectionSchema, weaponSectionSchema, } from "@/lib/guides/schemas"; +import { + normalizeTargetStat, + targetStatDefaults, + targetStatOptions, +} from "@/lib/guides/stats"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; import { rarityGradientClass } from "@/lib/catalog/rarity"; import { @@ -98,7 +126,6 @@ export interface GuideSectionEditorProps { }; } -const DEFAULT_TARGET_STATS = ["ATK", "EM", "ER", "CRIT Rate", "CRIT DMG"] as const; 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 }) { @@ -285,9 +312,16 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas const [circlet, setCirclet] = useState(profile?.circlet ?? []); const [substats, setSubstats] = useState(profile?.substats ?? []); const [targets, setTargets] = useState(() => { - const existing = profile?.targets ?? []; - 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 existing = new Map( + (profile?.targets ?? []).flatMap((target) => { + const stat = normalizeTargetStat(target.stat); + return stat ? [[stat, target.value] as const] : []; + }), + ); + return targetStatDefaults.map((target) => ({ + stat: target.stat, + value: existing.get(target.stat) ?? target.value, + })); }); const [talents, setTalents] = useState(() => { const source: TalentPriority[] = profile?.talents.length @@ -299,7 +333,7 @@ function ArtifactEditor({ guide, artifacts, initial, catalogTalents }: EditorBas }))); }); const [note, setNote] = useState(profile?.note ?? ""); - const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent), note: row.note?.trim() || null })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.stat.trim() && target.value.trim()), talents, note: note || null }; + const value = { groups: groups.map((group) => ({ ...group, rows: group.rows.map((row) => ({ ...row, percent: Number(row.percent), note: row.note?.trim() || null })) })), sands, goblet, circlet, substats, targets: targets.filter((target) => target.value.trim()).map((target) => ({ stat: target.stat, value: 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 }) }); const orderedTalents = normalizeTalentPriorities(talents); const handleTalentDragEnd = ({ active, over }: DragEndEvent) => { @@ -313,7 +347,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)