diff --git a/app/api/teams/[teamId]/image/route.tsx b/app/api/teams/[teamId]/image/route.tsx deleted file mode 100644 index f0317fc..0000000 --- a/app/api/teams/[teamId]/image/route.tsx +++ /dev/null @@ -1,432 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import { ImageResponse } from "next/og"; -import sharp from "sharp"; - -import { elementBackgroundColor } from "@/lib/catalog/element"; -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"; - -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 amount(value: number): string { - return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format( - 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(300, 300, { fit: "cover" }) - .png() - .toBuffer(); - return `data:image/png;base64,${png.toString("base64")}`; - } catch { - return null; - } -} - -export async function GET( - _request: Request, - context: RouteContext<"/api/teams/[teamId]/image">, -) { - const { teamId } = await context.params; - const team = await getCachedPublicTeamExport(teamId); - if (!team) return new Response("ไม่พบทีม", { status: 404 }); - - const total = team.members.reduce( - (sum, member) => sum + Number(member.damage), - 0, - ); - const seconds = Number(team.rotationSeconds); - const dps = seconds > 0 ? total / seconds : 0; - const [fonts, memberAssets] = await Promise.all([ - anuphanFonts, - Promise.all( - team.members.map(async (member) => { - const [character, weapon, artifact, secondArtifact] = await Promise.all( - [ - pngDataUrl(member.character.imageUrl), - member.weapon ? pngDataUrl(member.weapon.imageUrl) : null, - member.artifact ? pngDataUrl(member.artifact.imageUrl) : null, - member.secondArtifact - ? pngDataUrl(member.secondArtifact.imageUrl) - : null, - ], - ); - return { character, weapon, artifact, secondArtifact }; - }), - ), - ]); - - return new ImageResponse( -
-
-
-
- {team.guideName} -
-
- {team.title} · ระยะเวลาโรเทชั่น{" "} - {seconds.toLocaleString(undefined, { maximumFractionDigits: 2 })} วิ -
-
- -
- {team.members.map((member, index) => { - const damage = Number(member.damage); - const percent = total > 0 ? (damage / total) * 100 : 0; - return ( -
-
- {memberAssets[index].character ? ( - - ) : ( -
- {member.character.name.slice(0, 1)} -
- )} -
- C{member.constellation} -
-
-
- {member.weapon && memberAssets[index].weapon ? ( -
- -
- R{member.refinement} -
-
- ) : null} - {member.artifact && memberAssets[index].artifact ? ( -
- -
- ) : null} - {member.secondArtifact && - memberAssets[index].secondArtifact ? ( -
- -
- ) : null} -
-
-
-
- {member.character.name} -
-
- {amount(damage)} -
-
-
-
-
-
-
- {percent.toFixed(1)}% -
-
-
-
- ); - })} -
- -
-
{team.combo}
-
-
- - DPS: {amount(dps)} - -
-
- By YT เกนชินไม่ใช่เกมมือถือ -
-
-
, - { - width: 1080, - height: 1350, - fonts: fonts.length ? fonts : undefined, - headers: { - "Cache-Control": "no-store", - "Content-Disposition": imageContentDisposition(team.title, "team"), - "Content-Type": "image/png", - }, - }, - ); -} diff --git a/components/public/constellation-image.tsx b/components/public/constellation-image.tsx index a75b88f..8f5f3e4 100644 --- a/components/public/constellation-image.tsx +++ b/components/public/constellation-image.tsx @@ -53,11 +53,9 @@ export function ConstellationImage({ > {guide.character.name} Constellations - {sectionNote ? (

- {sectionNote} + Team Damage

- ) : null}
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 0509a39..56d573e 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -899,8 +899,6 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) { {guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => (
- {groups.length > 1 ?

Condition {groupIndex + 1}

: null} - {group.note ?

{group.note}

: null}
1 ? `-Condition-${groupIndex + 1}` : ""}.png`} @@ -915,6 +913,8 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) { />
+ {groups.length > 1 ?

Condition {groupIndex + 1}

: null} + {group.note ?

{group.note}

: null}
{group.rows.map((row) => ( diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx index 5ea277a..951fc44 100644 --- a/components/public/team-search.tsx +++ b/components/public/team-search.tsx @@ -2,10 +2,10 @@ import Image from "next/image"; import Link from "next/link"; -import { ArrowUpRightIcon, DownloadIcon, SearchIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { ArrowUpRightIcon, SearchIcon } from "lucide-react"; +import { useMemo, useState, type ReactNode } from "react"; -import { buttonVariants } from "@/components/ui/button"; +import { GuideImageExport } from "@/components/public/guide-image-export"; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Kbd } from "@/components/ui/kbd"; @@ -16,6 +16,213 @@ import { isCustomTravelerKey } from "@/lib/catalog/travelers"; import type { PublicGuide } from "@/lib/guides/queries"; import { cn } from "@/lib/utils"; +type Team = PublicGuide["teams"][number]; + +function TeamCard({ + team, + action, + exporting = false, +}: { + team: Team; + action?: ReactNode; + exporting?: boolean; +}) { + const total = team.members.reduce( + (sum, member) => sum + Number(member.damage), + 0, + ); + const seconds = Number(team.rotationSeconds); + + return ( + + + {team.title} + โรเทชั่น {seconds} วิ + {action ? {action} : null} + + +
+ {team.members.map((member) => ( +
+
+
+ {member.character.name} + + C{member.constellation} + +
+
+ {member.weapon ? ( + + {member.weapon.name} + + R{member.refinement} + + + + + ) : ( +
+ )} + {member.artifact ? ( + + {member.artifact.name} + + + + ) : ( +
+ )} + {member.secondArtifact ? ( + + {member.secondArtifact.name} + + + + ) : null} +
+
+

+ {member.character.name} +

+

+ {Number(member.damage).toLocaleString(undefined, { + maximumFractionDigits: 2, + })} +

+

+ {total + ? ((Number(member.damage) / total) * 100).toFixed(2) + : "0.00"} + % +

+
+ ))} +
+ +
+
+

ดาเมจรวม

+

{total.toLocaleString()}

+
+
+

DPS

+

+ {seconds > 0 + ? (total / seconds).toLocaleString(undefined, { + maximumFractionDigits: 0, + }) + : "0"} +

+
+
+

+ {team.combo} +

+ + + ); +} + export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) { const [query, setQuery] = useState(""); const normalizedQuery = query.trim().toLocaleLowerCase(); @@ -50,175 +257,17 @@ export function TeamSearch({ teams }: { teams: PublicGuide["teams"] }) {
{filteredTeams.length ? (
- {filteredTeams.map((team) => { - const total = team.members.reduce( - (sum, member) => sum + Number(member.damage), - 0, - ); - const seconds = Number(team.rotationSeconds); - return ( - - - {team.title} - โรเทชั่น {seconds} วิ - - - - ส่งออกภาพ - - - - -
- {team.members.map((member) => ( -
-
-
- {member.character.name} - - C{member.constellation} - -
-
- {member.weapon ? ( - - {member.weapon.name} - - R{member.refinement} - - - - - ) : ( -
- )} - {member.artifact ? ( - - {member.artifact.name} - - - - ) : ( -
- )} - {member.secondArtifact ? ( - - {member.secondArtifact.name} - - - - ) : null} -
-
-

- {member.character.name} -

-

- {Number(member.damage).toLocaleString(undefined, { - maximumFractionDigits: 2, - })} -

-

- {total - ? ((Number(member.damage) / total) * 100).toFixed(2) - : "0.00"} - % -

-
- ))} -
- -
-
-

- ดาเมจรวม -

-

{total.toLocaleString()}

-
-
-

DPS

-

- {seconds > 0 - ? (total / seconds).toLocaleString(undefined, { - maximumFractionDigits: 0, - }) - : "0"} -

-
-
-

- {team.combo} -

- - - ); - })} + {filteredTeams.map((team) => ( + + + + } + /> + ))}
) : ( diff --git a/lib/cache/public.ts b/lib/cache/public.ts index 3b5a891..7827223 100644 --- a/lib/cache/public.ts +++ b/lib/cache/public.ts @@ -3,11 +3,7 @@ import "server-only"; import { cacheLife, cacheTag } from "next/cache"; import { getPublicPage, listPublicCharacters } from "@/lib/content/queries"; -import { - getPublicGuide, - getPublicTeamExport, - listGuideCards, -} from "@/lib/guides/queries"; +import { getPublicGuide, listGuideCards } from "@/lib/guides/queries"; import { characterCacheTag, dataSourceCacheTag, @@ -61,11 +57,3 @@ export async function getCachedPublicGuide(slug: string) { if (result) cacheTag(pageCacheTag(result.id)); return result; } - -export async function getCachedPublicTeamExport(teamId: string) { - "use cache: remote"; - cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 }); - const result = await getPublicTeamExport(teamId); - if (result) cacheTag(pageCacheTag(result.guideId)); - return result; -} diff --git a/lib/guides/export-image.test.ts b/lib/guides/export-image.test.ts index af03753..ddb8dad 100644 --- a/lib/guides/export-image.test.ts +++ b/lib/guides/export-image.test.ts @@ -1,23 +1,8 @@ import { describe, expect, it } from "vitest"; -import { - constellationChartRows, - imageContentDisposition, -} from "@/lib/guides/export-image"; +import { constellationChartRows } from "@/lib/guides/export-image"; describe("guide image downloads", () => { - it("provides a safe ASCII fallback and a UTF-8 filename", () => { - expect(imageContentDisposition("ไกด์อาวุธ", "weapons")).toBe( - "attachment; filename=\"weapons.png\"; filename*=UTF-8''%E0%B9%84%E0%B8%81%E0%B8%94%E0%B9%8C%E0%B8%AD%E0%B8%B2%E0%B8%A7%E0%B8%B8%E0%B8%98.png", - ); - }); - - it("sanitizes punctuation in the fallback filename", () => { - expect(imageContentDisposition("Arlecchino / R5", "weapons")).toContain( - 'filename="Arlecchino-R5.png"', - ); - }); - it("orders constellation levels, adds C0, and scales against the maximum", () => { const rows = constellationChartRows([ { level: 1, cumulativePercent: "117", isGreat: false }, diff --git a/lib/guides/export-image.ts b/lib/guides/export-image.ts index 9fa6d1f..cf0ac73 100644 --- a/lib/guides/export-image.ts +++ b/lib/guides/export-image.ts @@ -1,16 +1,3 @@ -export function imageContentDisposition(displayName: string, fallbackName: string): string { - const safeDisplayName = displayName.trim() || fallbackName; - const fallback = safeDisplayName - .normalize("NFKD") - .replace(/[^a-z0-9]+/giu, "-") - .replace(/^-|-$/gu, "") || fallbackName; - const encoded = encodeURIComponent(`${safeDisplayName}.png`).replace( - /['()*]/gu, - (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, - ); - return `attachment; filename="${fallback}.png"; filename*=UTF-8''${encoded}`; -} - export interface ConstellationChartRow { level: number; percent: number; diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index ee24ad6..53ec007 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -142,57 +142,4 @@ export async function getPublicGuide(slug: string) { }; } -export async function getPublicTeamExport(teamId: string) { - const [row] = await getDb() - .select({ team: teamCompositions, guide: guides }) - .from(teamCompositions) - .innerJoin(guides, eq(guides.id, teamCompositions.guideId)) - .innerJoin( - guideSections, - and( - eq(guideSections.guideId, guides.id), - eq(guideSections.slug, "team"), - eq(guideSections.enabled, true), - ), - ) - .where(and(eq(teamCompositions.id, teamId), isNull(guides.trashedAt))) - .limit(1); - if (!row) return null; - - const members = await getDb() - .select({ member: teamMembers, character: catalogCharacters }) - .from(teamMembers) - .innerJoin( - catalogCharacters, - eq(catalogCharacters.key, teamMembers.characterKey), - ) - .where(eq(teamMembers.teamId, teamId)) - .orderBy(asc(teamMembers.position)); - - const weaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key)); - const artifactKeys = members.flatMap(({ member }) => [member.artifactKey, member.secondArtifactKey]).filter((key): key is string => Boolean(key)); - const [weapons, artifacts] = await Promise.all([ - weaponKeys.length ? getDb().select().from(catalogWeapons).where(inArray(catalogWeapons.key, weaponKeys)) : [], - artifactKeys.length ? getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, artifactKeys)) : [], - ]); - - return { - ...row.team, - guideId: row.guide.id, - guideName: row.guide.name, - members: members.map(({ member, character }) => { - const weapon = weapons.find((item) => item.key === member.weaponKey); - const artifact = artifacts.find((item) => item.key === member.artifactKey); - const secondArtifact = artifacts.find((item) => item.key === member.secondArtifactKey); - return { - ...member, - character: catalogCharacterWithUrls(character), - weapon: weapon ? { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } : null, - artifact: artifact ? { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) } : null, - secondArtifact: secondArtifact ? { ...secondArtifact, imageUrl: publicMediaUrl(secondArtifact.imageKey) } : null, - }; - }), - }; -} - export type PublicGuide = NonNullable>>;