diff --git a/app/api/guides/[guideId]/artifacts/image/route.tsx b/app/api/guides/[guideId]/artifacts/image/route.tsx deleted file mode 100644 index a63384f..0000000 --- a/app/api/guides/[guideId]/artifacts/image/route.tsx +++ /dev/null @@ -1,778 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; - -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 { - normalizeTargetStat, - recommendedStatIconName, - statPriorityColor, - targetStatIconName, - targetStatOptions, -} from "@/lib/guides/stats"; -import { - normalizeTalentPriorities, - talentRelationSymbol, - talentTypeLabel, -} from "@/lib/guides/talents"; - -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(() => []); - -const relationFont = readFile( - join(process.cwd(), "public", "Kanit-SemiBold.ttf"), -); - -function metric(value: string): string { - return `${Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 })}%`; -} - -function statLabel(value: string): string { - const labels: Record = { - HP: "พลังชีวิต", - ATK: "พลังโจมตี", - DEF: "พลังป้องกัน", - EM: "ความชำนาญธาตุ", - ER: "ฟื้นฟูพลังงาน", - "CRIT RATE": "อัตราคริ", - "CRIT DAMAGE": "ความแรงคริ", - "ATK%": "พลังโจมตี%", - "HP%": "พลังชีวิต%", - "DEF%": "พลังป้องกัน%", - "CRIT Rate%/CRIT DMG%": "อัตราคริ/ความแรงคริ", - "Pyro DMG%": "ความเสียหายธาตุไฟ%", - "Hydro DMG%": "ความเสียหายธาตุน้ำ%", - "Cryo DMG%": "ความเสียหายธาตุน้ำแข็ง%", - "Electro DMG%": "ความเสียหายธาตุไฟฟ้า%", - "Anemo DMG%": "ความเสียหายธาตุลม%", - "Geo DMG%": "ความเสียหายธาตุดิน%", - "Dendro DMG%": "ความเสียหายธาตุไม้%", - "Physical DMG%": "ความเสียหายกายภาพ%", - "Healing Bonus%": "โบนัสการรักษา%", - }; - return labels[value] ?? 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(96, 96, { fit: "contain" }) - .png() - .toBuffer(); - return `data:image/png;base64,${png.toString("base64")}`; - } catch { - return null; - } -} - -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({ alpha: false }); - 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/guides/[guideId]/artifacts/image">, -) { - const { guideId } = await context.params; - const guide = await getCachedPublicArtifactExport(guideId); - if (!guide) return new Response("ไม่พบไกด์", { status: 404 }); - const talents = normalizeTalentPriorities(guide.profile?.talents ?? []); - - const [fontAssets, groupAssets, statIconAssets, talentAssets, targetStatAssets] = - await Promise.all([ - Promise.all([anuphanFonts, relationFont]), - Promise.all( - guide.groups.map((group) => - Promise.all( - group.rows.map(async (row) => ({ - first: await pngDataUrl(row.first.imageUrl), - second: row.second ? await pngDataUrl(row.second.imageUrl) : null, - })), - ), - ), - ), - Promise.all([ - localPngDataUrl("Icon_Sands_of_Eon.webp"), - localPngDataUrl("Icon_Goblet_of_Eonothem.webp"), - localPngDataUrl("Icon_Circlet_of_Logos.webp"), - ]), - Promise.all( - talents.map((talent) => { - 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`, 96, true), - ), - ), - ]); - const [fonts, relationFontData] = fontAssets; - - const profile = guide.profile; - const recommendedStatAsset = (value: string) => { - const iconName = recommendedStatIconName(value); - const iconIndex = targetStatOptions.findIndex( - (stat) => targetStatIconName(stat) === iconName, - ); - return iconIndex >= 0 ? targetStatAssets[iconIndex] : null; - }; - 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 tierListHeight = - 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 * 40 + - (profile.talents.length ? 82 : 0) + - (profile.note - ? Math.max(48, Math.ceil(profile.note.length / 58) * 24) - : 0) - : 0; - const mainStatExtraHeight = profile - ? Math.max( - 0, - Math.max( - profile.sands.length, - profile.goblet.length, - profile.circlet.length, - ) - 2, - ) * 24 - : 0; - const profileHeight = profile - ? 242 + - mainStatExtraHeight + - (profile.substats.length ? 92 + profile.substats.length * 18 : 0) - : 0; - const leftHeight = tierListHeight + (profile ? profileHeight + 16 : 0); - const rightHeight = detailHeight > 60 ? detailHeight : 0; - const height = Math.max(520, 190 + Math.max(leftHeight, rightHeight)); - - return new ImageResponse( -
-
-
-
- อาร์ติแฟกต์และสแตทที่แนะนำ -
-
- Artifact & Stats -
-
- -
-
60 ? 1.2 : 1, - flexDirection: "column", - gap: 16, - }} - > - {guide.groups.map((group, groupIndex) => ( -
-
- {group.name === "Late Game" ? "เลทเกม" : group.name} -
-
- {group.rows.map((row, rowIndex) => ( -
-
- {groupAssets[groupIndex][rowIndex].first ? ( -
- -
- ) : null} - {row.second && - groupAssets[groupIndex][rowIndex].second ? ( -
- -
- ) : null} -
-
-
- {artifactTierCount(row.second?.name ?? null)}{" "} - {row.first.name} - {row.second ? ` + ${row.second.name}` : ""} -
- {row.note ? ( -
- {row.note} -
- ) : null} -
-
- {Number(row.percent) === 0 ? "-%" : metric(row.percent)} -
-
- ))} -
-
- ))} - - {profile ? ( -
-
- ออฟหลักที่ดีที่สุด -
-
- {[ - { - label: "นาฬิกา", - values: profile.sands, - icon: statIconAssets[0], - }, - { - label: "ถ้วย", - values: profile.goblet, - icon: statIconAssets[1], - }, - { - label: "หัว", - values: profile.circlet, - icon: statIconAssets[2], - }, - ].map((stat) => ( -
- -
- {stat.label} -
-
-
- {stat.values.length - ? stat.values.map((value, index) => ( - - {recommendedStatAsset(value) ? ( - - ) : null} - {statLabel(value)} - - )) - : "-"} -
-
- ))} -
-
- ) : null} - - {profile?.substats.length ? ( -
- ออฟรองที่ดีที่สุด -
- {profile.substats.map((stat, index) => ( - - {recommendedStatAsset(stat) ? ( - - ) : null} - {statLabel(stat)} - - ))} -
-
- ) : null} -
- - {profile && detailHeight > 60 ? ( -
-
- {profile.targets.length ? ( -
-
- ค่าสถานะต่ำๆที่ควรจะปั้นไว้ -
- {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 ? ( -
-
- ลำดับการอัพพรสวรรค์ -
-
- {talents.map((talent, index) => ( -
- {talentAssets[index] ? ( - - ) : null} - - - {talent.type} - {talent.level ? ` เลเวล ${talent.level}+` : ""} - - - {talentTypeLabel(talent.type)} - - - {index < talents.length - 1 ? ( - - {talentRelationSymbol(talent.relationToNext)} - - ) : null} -
- ))} -
-
- ) : null} - {profile.note ? ( -
-
- โน็ต -
-
- {profile.note} -
-
- ) : null} -
-
- ) : null} -
-
-
, - { - width: 1200, - height, - fonts: [ - ...fonts, - { - name: "Kanit", - data: relationFontData, - weight: 600, - style: "normal", - }, - ], - headers: { - "Cache-Control": "no-store", - "Content-Disposition": imageContentDisposition( - `${guide.guideName}-Artifact`, - "artifacts", - ), - "Content-Type": "image/png", - }, - }, - ); -} diff --git a/app/api/guides/[guideId]/weapons/image/route.tsx b/app/api/guides/[guideId]/weapons/image/route.tsx deleted file mode 100644 index 066d775..0000000 --- a/app/api/guides/[guideId]/weapons/image/route.tsx +++ /dev/null @@ -1,334 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -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"; - -const anuphanFonts = Promise.all( - [ - { subset: "latin", weight: 400 as const }, - { subset: "thai", weight: 400 as const }, - { subset: "latin", weight: 500 as const }, - { subset: "thai", weight: 500 as const }, - { subset: "latin", weight: 600 as const }, - { subset: "thai", weight: 600 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): string { - return Number(value) === 0 - ? "-%" - : `${Number(value).toLocaleString("en-US", { maximumFractionDigits: 2 })}%`; -} - -function wrappedLineCount(value: string, lineLength: number): number { - return value - .split(/\r?\n/u) - .reduce( - (lines, line) => lines + Math.max(1, Math.ceil(line.length / lineLength)), - 0, - ); -} - -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(80, 80, { 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 getCachedPublicWeaponExport(guideId); - if (!guide) return new Response("ไม่พบไกด์", { status: 404 }); - - const [fonts, weaponImages] = await Promise.all([ - anuphanFonts, - Promise.all(guide.weapons.map((row) => pngDataUrl(row.weapon.imageUrl))), - ]); - const weaponHeights = guide.weapons.map((row) => { - const noteLines = row.note ? wrappedLineCount(row.note, 58) : 0; - return Math.max(112, noteLines ? 64 + noteLines * 22 : 112); - }); - const noteLines = guide.note ? wrappedLineCount(guide.note, 70) : 0; - const headerHeight = 72 + (noteLines ? 12 + noteLines * 24 : 0); - const height = Math.max( - 360, - 64 + - headerHeight + - 24 + - weaponHeights.reduce((sum, rowHeight) => sum + rowHeight, 0) + - Math.max(0, guide.weapons.length - 1) * 12, - ); - - return new ImageResponse( -
-
-
-
- อาวุธที่แนะนำ -
-
- Weapons -
- {guide.note ? ( -
- {guide.note} -
- ) : null} -
- {guide.weapons.map((row, index) => ( -
-
-
- {weaponImages[index] ? ( - - ) : ( -
- {index + 1} -
- )} -
-
- {index + 1}. {row.weapon.name} R{row.refinement} -
- {row.note ? ( -
- {row.note} -
- ) : null} -
-
-
-
- {row.overallPercent ? ( -
-
- {metric(row.overallPercent)} -
-
- ดาเมททั้งทีม -
-
- ) : null} - {row.personalPercent ? ( -
-
- {metric(row.personalPercent)} -
-
- ดาเมจส่วนตัว -
-
- ) : null} -
-
- ))} -
-
, - { - width: 1000, - height, - fonts: fonts.length ? fonts : undefined, - headers: { - "Cache-Control": "no-store", - "Content-Disposition": imageContentDisposition( - `${guide.guideName}-Weapon`, - "weapons", - ), - "Content-Type": "image/png", - }, - }, - ); -} diff --git a/components/public/guide-image-export.tsx b/components/public/guide-image-export.tsx new file mode 100644 index 0000000..eef9e07 --- /dev/null +++ b/components/public/guide-image-export.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { DownloadIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; + +const EXPORT_WIDTH = 1200; + +function downloadFileName(value: string): string { + return value.replace(/[\\/:*?"<>|]/gu, "-"); +} + +export function GuideImageExport({ + children, + fileName, +}: { + children: ReactNode; + fileName: string; +}) { + const captureRef = useRef(null); + const [capturing, setCapturing] = useState(false); + + useEffect(() => { + if (!capturing) return; + + let cancelled = false; + + async function capture() { + try { + const node = captureRef.current; + if (!node) throw new Error("Capture target is unavailable"); + + const { domToBlob, waitUntilLoad } = await import("modern-screenshot"); + await Promise.all([document.fonts.ready, waitUntilLoad(node)]); + const blob = await domToBlob(node, { + backgroundColor: getComputedStyle(node).backgroundColor, + scale: 1, + width: EXPORT_WIDTH, + }); + if (cancelled) return; + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.download = downloadFileName(fileName); + link.href = url; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + } catch { + if (!cancelled) toast.error("ส่งออกภาพไม่สำเร็จ กรุณาลองอีกครั้ง"); + } finally { + if (!cancelled) setCapturing(false); + } + } + + void capture(); + return () => { + cancelled = true; + }; + }, [capturing, fileName]); + + return ( + <> + + {capturing + ? createPortal( + , + document.body, + ) + : null} + + ); +} diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index a14ef8d..74f17fc 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -1,9 +1,8 @@ import Image from "next/image"; import Link from "next/link"; -import { ArrowUpRightIcon, ChevronLeftIcon, DownloadIcon } from "lucide-react"; +import { ArrowUpRightIcon, ChevronLeftIcon } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { buttonVariants } from "@/components/ui/button"; import { Card, CardAction, @@ -16,6 +15,7 @@ import { Separator } from "@/components/ui/separator"; import { SiteHeader } from "@/components/public/site-header"; import { ScrollableNav } from "@/components/public/scrollable-nav"; import { TeamSearch } from "@/components/public/team-search"; +import { GuideImageExport } from "@/components/public/guide-image-export"; import { LiveRefresh } from "@/components/live-refresh"; import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity"; import type { PublicGuide } from "@/lib/guides/queries"; @@ -262,6 +262,27 @@ function metric(value: string | null) { } function WeaponSection({ guide }: { guide: PublicGuide }) { + return ( + + + + } + /> + ); +} + +function WeaponSectionContent({ + guide, + action, + exporting = false, +}: { + guide: PublicGuide; + action?: React.ReactNode; + exporting?: boolean; +}) { const note = guide.sections.find( (section) => section.slug === "weapon", )?.note; @@ -277,22 +298,25 @@ function WeaponSection({ guide }: { guide: PublicGuide }) { {note}

) : null} - - - ส่งออกภาพ - + {action}
{guide.weapons.map((row, index) => ( - +
@@ -301,12 +325,18 @@ function WeaponSection({ guide }: { guide: PublicGuide }) { src={row.weapon.imageUrl} alt={row.weapon.name} fill + loading={exporting ? "eager" : undefined} sizes="(min-width: 768px) 5rem, 4rem" className="object-contain" />
-

+

{index + 1}. {row.weapon.name} R{row.refinement}

{row.note ? ( @@ -319,7 +349,8 @@ function WeaponSection({ guide }: { guide: PublicGuide }) {

@@ -327,14 +358,22 @@ function WeaponSection({ guide }: { guide: PublicGuide }) { ? "-%" : metric(row.overallPercent)}

-

ดาเมททั้งทีม

+

+ ดาเมททั้งทีม +

) : null} {row.personalPercent ? (

@@ -342,7 +381,14 @@ function WeaponSection({ guide }: { guide: PublicGuide }) { ? "-%" : metric(row.personalPercent)}

-

ดาเมจส่วนตัว

+

+ ดาเมจส่วนตัว +

) : null}
@@ -355,6 +401,27 @@ function WeaponSection({ guide }: { guide: PublicGuide }) { } function ArtifactSection({ guide }: { guide: PublicGuide }) { + return ( + + + + } + /> + ); +} + +function ArtifactSectionContent({ + guide, + action, + exporting = false, +}: { + guide: PublicGuide; + action?: React.ReactNode; + exporting?: boolean; +}) { const profile = guide.artifactProfile; return (
@@ -365,16 +432,14 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {

Artifact & Stats

- - - ส่งออกภาพ - + {action} -
+
{" "}
{profile ? ( @@ -387,16 +452,19 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { iconSrc="/icon/Icon_Sands_of_Eon.webp" label="นาฬิกา" values={profile.sands} + exporting={exporting} /> @@ -411,6 +479,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { key={`${stat}-${index}`} value={stat} index={index} + exporting={exporting} /> ))}
@@ -441,7 +510,8 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {
@@ -449,6 +519,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { src={row.first.imageUrl} alt={row.first.name} fill + loading={exporting ? "eager" : undefined} sizes="(min-width: 640px) 4rem, 3.5rem" className="object-contain" /> @@ -456,7 +527,8 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { {row.second ? (
@@ -464,6 +536,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { src={row.second.imageUrl} alt={row.second.name} fill + loading={exporting ? "eager" : undefined} sizes="(min-width: 640px) 4rem, 3.5rem" className="object-contain" /> @@ -517,6 +590,7 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { alt="" width={14} height={14} + loading={exporting ? "eager" : undefined} className="size-3.5 shrink-0 object-contain brightness-0 invert" /> ) : null} @@ -537,7 +611,12 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) {

ลำดับการอัพพรสวรรค์

-
+
{normalizeTalentPriorities(profile.talents).map( (talent, index, talents) => { const catalogTalent = guide.character.talents.find( @@ -547,15 +626,29 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { return (
-
-
+
+
{catalogTalent?.iconUrl ? ( {talentName} ) : ( @@ -569,16 +662,31 @@ function ArtifactSection({ guide }: { guide: PublicGuide }) { ? `เลเวล ${talent.level}+` : "-"} -

+

{talentName}

-

+

{talentTypeLabel(talent.type)}

{index < talents.length - 1 ? ( +

{label}

-
+
{values.length ? ( values.map((value, index) => ( )) ) : ( @@ -677,7 +801,15 @@ function ArtifactStat({ ); } -function ArtifactStatValue({ value, index }: { value: string; index: number }) { +function ArtifactStatValue({ + value, + index, + exporting = false, +}: { + value: string; + index: number; + exporting?: boolean; +}) { const iconName = recommendedStatIconName(value); const hasIcon = iconName && @@ -695,6 +827,7 @@ function ArtifactStatValue({ value, index }: { value: string; index: number }) { alt="" width={20} height={20} + loading={exporting ? "eager" : undefined} className="size-5 shrink-0 object-contain brightness-0 invert" /> ) : null} diff --git a/lib/cache/public.ts b/lib/cache/public.ts index 90f0dfd..3b5a891 100644 --- a/lib/cache/public.ts +++ b/lib/cache/public.ts @@ -4,10 +4,8 @@ import { cacheLife, cacheTag } from "next/cache"; import { getPublicPage, listPublicCharacters } from "@/lib/content/queries"; import { - getPublicArtifactExport, getPublicGuide, getPublicTeamExport, - getPublicWeaponExport, listGuideCards, } from "@/lib/guides/queries"; import { @@ -71,19 +69,3 @@ export async function getCachedPublicTeamExport(teamId: string) { if (result) cacheTag(pageCacheTag(result.guideId)); return result; } - -export async function getCachedPublicWeaponExport(guideId: string) { - "use cache: remote"; - cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 }); - const result = await getPublicWeaponExport(guideId); - cacheTag(pageCacheTag(guideId)); - return result; -} - -export async function getCachedPublicArtifactExport(guideId: string) { - "use cache: remote"; - cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 }); - const result = await getPublicArtifactExport(guideId); - cacheTag(pageCacheTag(guideId)); - return result; -} diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index b48e142..cfddd62 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -187,102 +187,4 @@ export async function getPublicTeamExport(teamId: string) { }; } -export async function getPublicWeaponExport(guideId: string) { - const [row] = await getDb() - .select({ guide: guides, note: guideSections.note }) - .from(guides) - .innerJoin( - guideSections, - and( - eq(guideSections.guideId, guides.id), - eq(guideSections.slug, "weapon"), - eq(guideSections.enabled, true), - ), - ) - .where(and(eq(guides.id, guideId), isNull(guides.trashedAt))) - .limit(1); - if (!row) return null; - - const weapons = await getDb() - .select({ recommendation: weaponRecommendations, weapon: catalogWeapons }) - .from(weaponRecommendations) - .innerJoin( - catalogWeapons, - eq(catalogWeapons.key, weaponRecommendations.weaponKey), - ) - .where(eq(weaponRecommendations.guideId, guideId)) - .orderBy(asc(weaponRecommendations.sortOrder)); - - return { - guideName: row.guide.name, - note: row.note, - weapons: weapons.map(({ recommendation, weapon }) => ({ - ...recommendation, - weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) }, - })), - }; -} - -export async function getPublicArtifactExport(guideId: string) { - const [row] = await getDb() - .select({ guide: guides, character: catalogCharacters }) - .from(guides) - .innerJoin( - guideSections, - and( - eq(guideSections.guideId, guides.id), - eq(guideSections.slug, "artifact"), - eq(guideSections.enabled, true), - ), - ) - .innerJoin( - catalogCharacters, - eq(catalogCharacters.key, guides.characterKey), - ) - .where(and(eq(guides.id, guideId), isNull(guides.trashedAt))) - .limit(1); - if (!row) return null; - - const [groups, profile] = await Promise.all([ - getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, guideId)).orderBy(asc(artifactGroups.sortOrder)), - getDb().select().from(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)).limit(1), - ]); - const recommendations = groups.length - ? await getDb() - .select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }) - .from(artifactRecommendations) - .innerJoin( - catalogArtifacts, - eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey), - ) - .where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))) - .orderBy(asc(artifactRecommendations.sortOrder)) - : []; - const secondKeys = recommendations - .map(({ recommendation }) => recommendation.secondArtifactKey) - .filter((key): key is string => Boolean(key)); - const secondArtifacts = secondKeys.length - ? await getDb().select().from(catalogArtifacts).where(inArray(catalogArtifacts.key, secondKeys)) - : []; - - return { - guideName: row.guide.name, - character: catalogCharacterWithUrls(row.character), - groups: groups.map((group) => ({ - ...group, - rows: recommendations - .filter(({ recommendation }) => recommendation.groupId === group.id) - .map(({ recommendation, artifact }) => { - const second = secondArtifacts.find((item) => item.key === recommendation.secondArtifactKey); - return { - ...recommendation, - first: { ...artifact, imageUrl: publicMediaUrl(artifact.imageKey) }, - second: second ? { ...second, imageUrl: publicMediaUrl(second.imageKey) } : null, - }; - }), - })), - profile: profile[0] ?? null, - }; -} - export type PublicGuide = NonNullable>>;