diff --git a/app/admin/generate/image/page.tsx b/app/admin/generate/image/page.tsx new file mode 100644 index 0000000..f6e9145 --- /dev/null +++ b/app/admin/generate/image/page.tsx @@ -0,0 +1,56 @@ +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { AdminHeader } from "@/components/admin/admin-header"; +import { OverviewImageGenerator } from "@/components/admin/overview-image-generator"; +import { getAdminSession } from "@/lib/auth/server"; +import { getActiveCatalog } from "@/lib/guides/queries"; +import { elementOrder } from "@/lib/overview-image"; + +export default async function OverviewImagePage() { + await connection(); + if (!(await getAdminSession())) redirect("/admin/login"); + + const catalog = await getActiveCatalog(); + if (!catalog) redirect("/admin?sync=required"); + + const elementIconUrls = Object.fromEntries( + elementOrder.map((element) => [ + element, + catalog.characters.find( + (character) => + character.element === element && character.elementIconUrl, + )?.elementIconUrl ?? null, + ]), + ); + + return ( +
+ +
+
+

Image generator

+

+ สร้างภาพ Overview +

+

+ สร้างและดาวน์โหลดภาพ PNG ขนาด 4K โดยไม่เปลี่ยนภาพใน Guide +

+
+ ({ + key: character.key, + name: character.name, + aliases: character.aliases, + element: character.element, + rarity: character.rarity, + weaponType: character.weaponType, + imageUrl: character.imageUrl, + nameCardImageUrl: character.nameCardImageUrl, + }))} + elementIconUrls={elementIconUrls} + /> +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index ffed109..ded48a2 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,6 +3,12 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@font-face { + font-family: "Genshin Overview"; + src: url("/zh-cn.ttf") format("truetype"); + font-display: swap; +} + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/bun.lock b/bun.lock index 118ba50..a04c6fa 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@origin-space/image-cropper": "^0.1.9", "@tailwindcss/typography": "0.5.20", "@tiptap/core": "3.31.3", "@tiptap/extension-link": "3.31.3", @@ -380,6 +381,8 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@origin-space/image-cropper": ["@origin-space/image-cropper@0.1.9", "", { "peerDependencies": { "react": "^19.1.0", "react-dom": "^19.1.0" } }, "sha512-Ebl57I6beh7uKjSv8+r3VObEoOXIXhx4SicDUdxa9U7NVY18SzEGfqPDuhNyXuFIB5G/S8nYUToNlIRp9wwB9Q=="], + "@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.7", "", {}, "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q=="], diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 5957447..79ac6e2 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { ExternalLinkIcon, + ImageIcon, LayoutDashboardIcon, BookOpenIcon, LogOutIcon, @@ -66,6 +67,17 @@ export function AdminHeader() { New Guide + + + ใช้ Namecard ของตัวละครใต้ภาพโปร่งใส + + + + + ภาพตัวละคร 1:1 + + + ทุกภาพจะเปิดหน้าต่างให้ครอป ซูม และจัดตำแหน่งก่อนใช้งาน + + {artworkError ? {artworkError} : null} + + + + Role + { + const selected = value[0] as OverviewRole | undefined; + if (selected) setRole(selected); + }} + variant="outline" + className="flex-wrap justify-start" + > + {overviewRoles.map((value) => ( + + {value} + + ))} + + + + + + + + + + + + + + + + + ตัวอย่าง + ภาพดาวน์โหลดจะใช้เลย์เอาต์เดียวกับตัวอย่าง + + +
+ +
+
+
+ + + + { + if (!open) setPendingCrop(null); + }} + > + + + ครอปภาพเป็น 1:1 + + เลื่อนตำแหน่งภาพให้ส่วนที่ต้องการอยู่ภายในกรอบสี่เหลี่ยม + + + {pendingCrop ? ( +
+ + + ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม + + + + +
+ + +
+ + ซูม +
+
+
+
+ ) : null} + + + + +
+
+ + + + + ครอปพื้นหลัง Namecard + + ลากและซูม Namecard เพื่อเลือกพื้นที่ด้านหลังตัวละคร + + + {character?.nameCardImageUrl ? ( +
+ + + ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นหลังสี่เหลี่ยม + + + + + + ซูม +
+
+
+
+ ) : null} + + + + +
+
+ + ); +} diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx index 47528a2..be1477f 100644 --- a/components/public/team-search.tsx +++ b/components/public/team-search.tsx @@ -168,7 +168,7 @@ function TeamExportImage({ team }: { team: Team }) { const percent = total > 0 ? damage / total * 100 : 0; return (
-
+
C{member.constellation}
diff --git a/components/ui/cropper.tsx b/components/ui/cropper.tsx new file mode 100644 index 0000000..7348016 --- /dev/null +++ b/components/ui/cropper.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Cropper as CropperPrimitive } from "@origin-space/image-cropper"; + +import { cn } from "@/lib/utils"; + +function Cropper({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CropperDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CropperImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CropperCropArea({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Cropper, CropperCropArea, CropperDescription, CropperImage }; diff --git a/lib/overview-image.test.ts b/lib/overview-image.test.ts new file mode 100644 index 0000000..46d591f --- /dev/null +++ b/lib/overview-image.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + accentGradient, + normalizeHex, + overviewFileName, + weaponTypeIconPath, +} from "@/lib/overview-image"; + +describe("overview image generator", () => { + it("normalizes valid colors and rejects invalid colors", () => { + expect(normalizeHex(" #e9a51a ")).toBe("#E9A51A"); + expect(normalizeHex("#fff")).toBeNull(); + expect(normalizeHex("orange")).toBeNull(); + }); + + it("creates a deterministic accent gradient", () => { + expect(accentGradient("#E9A51A")).toBe( + "linear-gradient(90deg, #CD9117 0%, #E9A51A 25%, #F1C56A 50%, #E9A51A 75%, #CD9117 100%)", + ); + expect(accentGradient("#FF0000", "#0000FF")).toBe( + "linear-gradient(90deg, #E00000 0%, #FF0000 25%, #AC59AC 50%, #0000FF 75%, #0000E0 100%)", + ); + }); + + it("maps every catalog weapon type to a PNG", () => { + expect(weaponTypeIconPath("WEAPON_SWORD_ONE_HAND")).toBe( + "/icon/weapon-types/sword.png", + ); + expect(weaponTypeIconPath("WEAPON_CLAYMORE")).toBe( + "/icon/weapon-types/claymore.png", + ); + expect(weaponTypeIconPath("WEAPON_POLE")).toBe( + "/icon/weapon-types/polearm.png", + ); + expect(weaponTypeIconPath("WEAPON_BOW")).toBe( + "/icon/weapon-types/bow.png", + ); + expect(weaponTypeIconPath("WEAPON_CATALYST")).toBe( + "/icon/weapon-types/catalyst.png", + ); + expect(weaponTypeIconPath(null)).toBeNull(); + }); + + it("creates a safe PNG filename", () => { + expect(overviewFileName("Traveler / Pyro", "Main DPS")).toBe( + "overview-Traveler-Pyro-main-dps.png", + ); + }); + +}); diff --git a/lib/overview-image.ts b/lib/overview-image.ts new file mode 100644 index 0000000..26a268d --- /dev/null +++ b/lib/overview-image.ts @@ -0,0 +1,75 @@ +export const overviewRoles = ["Main DPS", "Sub DPS", "Support"] as const; + +export type OverviewRole = (typeof overviewRoles)[number]; + +export const elementOrder = [ + "Pyro", + "Hydro", + "Anemo", + "Electro", + "Dendro", + "Cryo", + "Geo", +] as const; + +const weaponIconNames: Record = { + WEAPON_SWORD_ONE_HAND: "sword.png", + WEAPON_CLAYMORE: "claymore.png", + WEAPON_POLE: "polearm.png", + WEAPON_BOW: "bow.png", + WEAPON_CATALYST: "catalyst.png", +}; + +export function normalizeHex(value: string): string | null { + const trimmed = value.trim(); + if (!/^#[\da-f]{6}$/iu.test(trimmed)) return null; + return trimmed.toUpperCase(); +} + +function mixHex(hex: string, target: number, amount: number): string { + const normalized = normalizeHex(hex); + if (!normalized) return hex; + const channels = [1, 3, 5].map((offset) => + Number.parseInt(normalized.slice(offset, offset + 2), 16), + ); + return `#${channels + .map((channel) => + Math.round(channel + (target - channel) * amount) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`.toUpperCase(); +} + +function mixColors(left: string, right: string): string { + const leftHex = normalizeHex(left) ?? "#E9A51A"; + const rightHex = normalizeHex(right) ?? leftHex; + const channels = [1, 3, 5].map((offset) => + Math.round( + (Number.parseInt(leftHex.slice(offset, offset + 2), 16) + + Number.parseInt(rightHex.slice(offset, offset + 2), 16)) / + 2, + ), + ); + return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`.toUpperCase(); +} + +export function accentGradient(start: string, end = start): string { + const normalizedStart = normalizeHex(start) ?? "#E9A51A"; + const normalizedEnd = normalizeHex(end) ?? normalizedStart; + const startEdge = mixHex(normalizedStart, 0, 0.12); + const endEdge = mixHex(normalizedEnd, 0, 0.12); + const highlight = mixHex(mixColors(normalizedStart, normalizedEnd), 255, 0.35); + return `linear-gradient(90deg, ${startEdge} 0%, ${normalizedStart} 25%, ${highlight} 50%, ${normalizedEnd} 75%, ${endEdge} 100%)`; +} + +export function weaponTypeIconPath(weaponType: string | null): string | null { + const fileName = weaponType ? weaponIconNames[weaponType] : undefined; + return fileName ? `/icon/weapon-types/${fileName}` : null; +} + +export function overviewFileName(characterKey: string, role: OverviewRole): string { + const safeKey = characterKey.replace(/[^\p{L}\p{N}._-]+/gu, "-"); + const safeRole = role.toLowerCase().replaceAll(" ", "-"); + return `overview-${safeKey}-${safeRole}.png`; +} diff --git a/package.json b/package.json index 6e15b11..a4af439 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@origin-space/image-cropper": "^0.1.9", "@tailwindcss/typography": "0.5.20", "@tiptap/core": "3.31.3", "@tiptap/extension-link": "3.31.3", diff --git a/public/icon/weapon-types/bow.png b/public/icon/weapon-types/bow.png new file mode 100644 index 0000000..111c8a2 Binary files /dev/null and b/public/icon/weapon-types/bow.png differ diff --git a/public/icon/weapon-types/catalyst.png b/public/icon/weapon-types/catalyst.png new file mode 100644 index 0000000..7b5a4f5 Binary files /dev/null and b/public/icon/weapon-types/catalyst.png differ diff --git a/public/icon/weapon-types/claymore.png b/public/icon/weapon-types/claymore.png new file mode 100644 index 0000000..d6a4c12 Binary files /dev/null and b/public/icon/weapon-types/claymore.png differ diff --git a/public/icon/weapon-types/polearm.png b/public/icon/weapon-types/polearm.png new file mode 100644 index 0000000..8af2091 Binary files /dev/null and b/public/icon/weapon-types/polearm.png differ diff --git a/public/icon/weapon-types/sword.png b/public/icon/weapon-types/sword.png new file mode 100644 index 0000000..3fef168 Binary files /dev/null and b/public/icon/weapon-types/sword.png differ diff --git a/public/img/aura-down.png b/public/img/aura-down.png new file mode 100644 index 0000000..4279b63 Binary files /dev/null and b/public/img/aura-down.png differ diff --git a/public/img/aura-up.png b/public/img/aura-up.png new file mode 100644 index 0000000..1ebdfcd Binary files /dev/null and b/public/img/aura-up.png differ diff --git a/public/img/aura.png b/public/img/aura.png new file mode 100644 index 0000000..02724c2 Binary files /dev/null and b/public/img/aura.png differ diff --git a/public/img/badge.png b/public/img/badge.png new file mode 100644 index 0000000..ab207b8 Binary files /dev/null and b/public/img/badge.png differ diff --git a/public/img/fade.png b/public/img/fade.png new file mode 100644 index 0000000..f2509f1 Binary files /dev/null and b/public/img/fade.png differ