From e0b25500bc352bd36ea56a1924c0c87c8194b211 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Wed, 23 Sep 2026 21:45:46 +0700 Subject: [PATCH] feat : server side --- app/[character]/[page]/page.tsx | 15 +- .../guides/[slug]/artifact-export/route.ts | 24 + components/public/artifact-image-export.tsx | 62 +- .../public/artifact-section-content.tsx | 561 +++++++++++++++ components/public/condition-switcher.tsx | 34 +- components/public/constellation-image.tsx | 4 +- components/public/structured-guide.tsx | 666 +++--------------- components/public/team-search.tsx | 130 +++- lib/guides/public-filters.test.ts | 98 +++ lib/guides/public-filters.ts | 143 ++++ 10 files changed, 1126 insertions(+), 611 deletions(-) create mode 100644 app/api/public/guides/[slug]/artifact-export/route.ts create mode 100644 components/public/artifact-section-content.tsx create mode 100644 lib/guides/public-filters.test.ts create mode 100644 lib/guides/public-filters.ts diff --git a/app/[character]/[page]/page.tsx b/app/[character]/[page]/page.tsx index 0794904..8de5380 100644 --- a/app/[character]/[page]/page.tsx +++ b/app/[character]/[page]/page.tsx @@ -4,8 +4,10 @@ import { notFound } from "next/navigation"; import { GoogleAdsense } from "@/components/public/google-adsense"; import { publicSections, StructuredGuideShell } from "@/components/public/structured-guide"; import { getCachedPublicGuide, getCachedPublicGuideSummary } from "@/lib/cache/public"; +import { resolvePublicGuideFilters, type GuideSearchParams } from "@/lib/guides/public-filters"; type Params = Promise<{ character: string; page: string }>; +type SearchParams = Promise; export const instant = false; @@ -22,14 +24,21 @@ export async function generateMetadata({ params }: { params: Params }): Promise< : { title: "ไม่พบไกด์" }; } -export default async function GuideSectionPage({ params }: { params: Params }) { - const { character, page } = await params; +export default async function GuideSectionPage({ + params, + searchParams, +}: { + params: Params; + searchParams: SearchParams; +}) { + const [{ character, page }, query] = await Promise.all([params, searchParams]); const guide = await getCachedPublicGuide(character, page); if (!guide || !publicSections(guide).some((section) => section.slug === page)) notFound(); + const filters = resolvePublicGuideFilters(guide, page, query); return ( <> - + ); } diff --git a/app/api/public/guides/[slug]/artifact-export/route.ts b/app/api/public/guides/[slug]/artifact-export/route.ts new file mode 100644 index 0000000..30d30d3 --- /dev/null +++ b/app/api/public/guides/[slug]/artifact-export/route.ts @@ -0,0 +1,24 @@ +import { getCachedPublicGuide } from "@/lib/cache/public"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const guide = await getCachedPublicGuide(slug, "artifact"); + if (!guide) return Response.json({ error: "guide-not-found" }, { status: 404 }); + + return Response.json({ + name: guide.name, + coverUrl: guide.coverUrl, + character: { + splashImageUrl: guide.character.splashImageUrl, + talents: guide.character.talents, + }, + elementIconUrls: guide.elementIconUrls, + artifactProfile: guide.artifactProfile, + artifactGroups: guide.artifactGroups, + }, { + headers: { "Cache-Control": "public, max-age=60, stale-while-revalidate=300" }, + }); +} diff --git a/components/public/artifact-image-export.tsx b/components/public/artifact-image-export.tsx index 7440af0..dd958e1 100644 --- a/components/public/artifact-image-export.tsx +++ b/components/public/artifact-image-export.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { DownloadIcon } from "lucide-react"; import { GuideImageExport } from "@/components/public/guide-image-export"; @@ -16,6 +16,7 @@ import { } from "@/components/ui/dialog"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { ArtifactSectionContent, type ArtifactSectionGuide } from "@/components/public/artifact-section-content"; const sectionOptions = [ ["tierlist", "อันดับอาร์ติแฟกต์"], @@ -27,20 +28,26 @@ const sectionOptions = [ type ArtifactExportSection = (typeof sectionOptions)[number][0]; export function ArtifactImageExport({ - children, fileName, hasNote, + slug, + initialData, targetConditionLabels, recommendedConditionLabels, tierConditionLabels, }: { - children: ReactNode; fileName: string; hasNote: boolean; + slug: string; + initialData?: ArtifactSectionGuide; targetConditionLabels: readonly string[]; recommendedConditionLabels: readonly string[]; tierConditionLabels: readonly string[]; }) { + const [open, setOpen] = useState(false); + const [exportData, setExportData] = useState(initialData ?? null); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); const [sections, setSections] = useState>( () => new Set(sectionOptions.map(([value]) => value)), ); @@ -73,8 +80,25 @@ export function ArtifactImageExport({ ? ".artifact-export-grid{display:block!important}.artifact-export-right{width:560px!important;max-width:100%;margin-left:auto!important;margin-right:auto!important;justify-self:center!important}" : ""; + async function loadExportData() { + setLoading(true); + setFailed(false); + try { + const response = await fetch(`/api/public/guides/${encodeURIComponent(slug)}/artifact-export`); + if (!response.ok) throw new Error("artifact-export-load-failed"); + setExportData(await response.json() as ArtifactSectionGuide); + } catch { + setFailed(true); + } finally { + setLoading(false); + } + } + return ( - + { + setOpen(nextOpen); + if (nextOpen && !exportData && !loading) void loadExportData(); + }}> }> ส่งออกภาพ @@ -167,17 +191,25 @@ export function ArtifactImageExport({ ) : null} - - - {children} - + {loading ?

กำลังเตรียมข้อมูลส่งออก…

: null} + {failed ? ( + + ) : null} + {exportData ? ( + + + + + ) : null}
); diff --git a/components/public/artifact-section-content.tsx b/components/public/artifact-section-content.tsx new file mode 100644 index 0000000..7b65c24 --- /dev/null +++ b/components/public/artifact-section-content.tsx @@ -0,0 +1,561 @@ +"use client"; + +import Image from "next/image"; +import type { ReactNode } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { TeamArtifactIcon } from "@/components/public/team-search"; +import { elementIconFilter } from "@/lib/catalog/element"; +import { ExportText } from "@/components/public/export-language"; +import { GuideExportBranding } from "@/components/public/guide-export-branding"; +import { ConditionSwitcher } from "@/components/public/condition-switcher"; +import type { PublicGuide } from "@/lib/guides/queries"; +import { artifactTierCount } from "@/lib/guides/artifacts"; +import { normalizeTargetStat, recommendedStatElement, recommendedStatIconName, statPriorityColorClass, targetStatIconName } from "@/lib/guides/stats"; +import { normalizeTalentPriorities, talentRelationSymbol } from "@/lib/guides/talents"; +import { cn } from "@/lib/utils"; + +export type ArtifactSectionGuide = Pick & { + character: Pick; +}; + +export interface ArtifactConditionSelection { + recommendedIndex: number; + setsIndex: number; + targetsIndex: number; + labels: { + recommended: readonly string[]; + sets: readonly string[]; + targets: readonly string[]; + }; +} + +function metric(value: string | null, locale?: string) { + return value === null ? null : `${Number(value).toLocaleString(locale, { maximumFractionDigits: 2 })}%`; +} + +function exportLabel(exporting: boolean, th: string, en: string): ReactNode { + return exporting ? : th; +} + +export function ArtifactSectionContent({ + guide, + action, + exporting = false, + selection, +}: { + guide: ArtifactSectionGuide; + action?: ReactNode; + exporting?: boolean; + selection?: ArtifactConditionSelection; +}) { + const profile = guide.artifactProfile; + const recommendedConditions = profile?.recommendedConditions ?? []; + const recommendedLabels = selection?.labels.recommended ?? recommendedConditions.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`); + const recommendedCards = recommendedConditions.map((condition, conditionIndex) => ( +
+ {exporting && recommendedConditions.length > 1 ?

{condition.name || }

: null} + {condition.note ?

{condition.note}

: null} + {profile ? ( + + + {exportLabel(exporting, "ออฟหลักที่ดีที่สุด", "Best Main Stats")} + + + + + + + + ) : null} + {condition.substats.length ? ( + + + {exportLabel(exporting, "ออฟรองที่ดีที่สุด", "Best Substats")} +
+ {condition.substats.map((stat, index) => ( + + ))} +
+
+
+ ) : null} + +
+ )); + + return ( +
+ {exporting ? ( + + ) : null} +
+
+

+ อาร์ติแฟกต์และสแตทที่แนะนำ +

+
+ +

+ Artifacts & Stats +

+
+ {action} +
+
+
+ {" "} +
+ {exporting ? recommendedCards : ( + + {recommendedCards} + + )} +
+ {(() => { + const tierCards = guide.artifactGroups.map((condition, conditionIndex) => ( +
+ {exporting && guide.artifactGroups.length > 1 ?

{condition.name || }

: null} + {condition.note ?

{condition.note}

: null} + {condition.tiers.map((group) => + + + {group.name === "Late Game" + ? exportLabel(exporting, "เลทเกม", "Late Game") + : group.name === "Early Game" + ? exportLabel(exporting, "ต้นเกม", "Early Game") + : group.name} + + + + {group.rows.map((row, index) => ( +
+ +
+

+ {artifactTierCount(row.second?.name ?? null)}{" "} + {row.first.name} + {row.second ? ` + 2 ${row.second.name}` : ""} +

+ {row.note ? ( +

+ {row.note} +

+ ) : null} +
+

+ {Number(row.percent) === 0 ? "-%" : metric(row.percent, exporting ? "en-US" : undefined)} +

+
+ ))} +
+
)} +
+ )); + const labels = selection?.labels.sets ?? guide.artifactGroups.map((condition, index) => condition.name || `เงื่อนไข ${index + 1}`); + return exporting ? tierCards : {tierCards}; + })()} +
+
+ {profile ? ( +
+ + + + + {profile.targetConditions.some((group) => group.rows.length) ? ( + exporting ? ( + profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => ( +
+

{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}

+ {group.note ?

{group.note}

: null} +
+ {group.rows.map((target, index) => ( +
+ + {targetStatIconName(target.stat) ? ( + + ) : null} + + {statLabel( + normalizeTargetStat(target.stat) ?? target.stat, exporting, + )} + + + {target.value} +
+ ))} +
+
+ )) + ) : ( + group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} selectedIndex={selection?.targetsIndex} queryParam={selection ? "targets" : undefined}> + {profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => ( +
+

{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}

+ {group.note ?

{group.note}

: null} +
+ {group.rows.map((target, index) => ( +
+ + {targetStatIconName(target.stat) ? : null} + {statLabel(normalizeTargetStat(target.stat) ?? target.stat)} + + {target.value} +
+ ))} +
+
+ ))} +
+ ) + ) : null} + {profile.talents.length ? ( +
+

+ {exportLabel(exporting, "ลำดับการอัพพรสวรรค์", "Talent Priority")} +

+
+ {normalizeTalentPriorities(profile.talents).map( + (talent, index, talents) => { + const catalogTalent = guide.character.talents.find( + (item) => item.type === talent.type, + ); + const talentName = catalogTalent?.name ?? talent.type; + return ( +
+
+
+ {catalogTalent?.iconUrl ? ( + {talentName} + ) : ( + + {talent.type} + + )} +
+ + {talent.level + ? exportLabel(exporting, `เลเวล ${talent.level}+`, `Level ${talent.level}+`) + : "-"} + +

+ {talentName} +

+

+ {talent.type === "E" + ? exportLabel(exporting, "สกิลธาตุ", "Elemental Skill") + : talent.type === "Q" + ? exportLabel(exporting, "ท่าไม้ตาย", "Elemental Burst") + : exportLabel(exporting, "ตีปกติ", "Normal Attack")} +

+
+ {index < talents.length - 1 ? ( + + {talentRelationSymbol(talent.relationToNext)} + + ) : null} +
+ ); + }, + )} +
+
+ ) : null} + {profile.note ? ( +
+

{exportLabel(exporting, "โน็ต", "Notes")}

+

+ {profile.note} +

+
+ ) : null} +
+
+
+ ) : null} +
+
+ ); +} + +const thaiStats: 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%": "โบนัสการรักษา", +}; + +const englishStats: Record = { + HP: "HP", + ATK: "ATK", + DEF: "DEF", + EM: "Elemental Mastery", + ER: "Energy Recharge", + "CRIT RATE": "CRIT Rate", + "CRIT DAMAGE": "CRIT DMG", + "CRIT Rate/CRIT DMG": "CRIT Rate / CRIT DMG", +}; + +const statLabelAliases: Record = { + "CRIT Rate%": "CRIT RATE", + "CRIT DMG%": "CRIT DAMAGE", + "CRIT Rate%/CRIT DMG%": "CRIT Rate/CRIT DMG", +}; + +function statLabel(value: string, exporting = false): ReactNode { + const labelKey = statLabelAliases[value] ?? value; + if (!exporting) return thaiStats[labelKey] ?? value; + return ( + + ); +} + +function ArtifactStat({ + iconSrc, + label, + values, + exporting = false, + elementIconUrls, +}: { + iconSrc: string; + label: ReactNode; + values: string[]; + exporting?: boolean; + elementIconUrls: Record; +}) { + return ( +
+ +

{label}

+ +
+ {values.length ? ( + values.map((value, index) => ( + + )) + ) : ( + - + )} +
+
+ ); +} + +function ArtifactStatValue({ + value, + index, + exporting = false, + elementIconUrls, +}: { + value: string; + index: number; + exporting?: boolean; + elementIconUrls: Record; +}) { + const iconName = recommendedStatIconName(value); + const element = recommendedStatElement(value); + const elementIconUrl = element ? elementIconUrls[element] : null; + const hasIcon = Boolean(iconName); + return ( + + {elementIconUrl ? ( + + ) : hasIcon ? ( + + ) : null} + {statLabel(value, exporting)} + + ); +} diff --git a/components/public/condition-switcher.tsx b/components/public/condition-switcher.tsx index 96a4537..88ae2bf 100644 --- a/components/public/condition-switcher.tsx +++ b/components/public/condition-switcher.tsx @@ -1,6 +1,7 @@ "use client"; import { Children, type ReactNode, useState } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ScrollableNav } from "@/components/public/scrollable-nav"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; @@ -9,34 +10,55 @@ export function ConditionSwitcher({ children, label = "เลือก Condition", labels, + selectedIndex, + queryParam, }: { children: ReactNode; label?: string; labels?: readonly string[]; + selectedIndex?: number; + queryParam?: string; }) { const items = Children.toArray(children); - const [active, setActive] = useState("0"); + const [localActive, setLocalActive] = useState("0"); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const active = selectedIndex === undefined ? localActive : String(selectedIndex); + const optionCount = labels?.length ?? items.length; - if (items.length <= 1) return items[0] ?? null; + if (optionCount <= 1) return items[0] ?? null; return (
{ - if (values[0]) setActive(values[0]); + const nextValue = values[0]; + if (!nextValue) return; + if (selectedIndex === undefined || !queryParam) { + setLocalActive(nextValue); + return; + } + + const nextParams = new URLSearchParams(searchParams.toString()); + const nextIndex = Number(nextValue); + if (nextIndex === 0) nextParams.delete(queryParam); + else nextParams.set(queryParam, String(nextIndex + 1)); + const query = nextParams.toString(); + router.push(`${pathname}${query ? `?${query}` : ""}`, { scroll: false }); }}> - {items.map((_, index) => ( + {(labels ?? items.map((_, index) => `Condition ${index + 1}`)).map((text, index) => ( - {labels?.[index] || `Condition ${index + 1}`} + {text} ))} - {items[Number(active)]} + {selectedIndex === undefined ? items[Number(active)] : items[0]}
); } diff --git a/components/public/constellation-image.tsx b/components/public/constellation-image.tsx index f22cc98..2f5d4d2 100644 --- a/components/public/constellation-image.tsx +++ b/components/public/constellation-image.tsx @@ -14,7 +14,9 @@ export function ConstellationImage({ guide, group, }: { - guide: PublicGuide; + guide: Pick & { + character: Pick; + }; group: PublicGuide["constellationGroups"][number]; }) { const rows = constellationChartRows(group.rows); diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index d5c8308..0dda0cb 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -14,7 +14,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; import { Table, TableBody, @@ -26,12 +25,13 @@ import { import { SiteFooter } from "@/components/public/site-footer"; import { SiteHeader } from "@/components/public/site-header"; import { ScrollableNav } from "@/components/public/scrollable-nav"; -import { TeamArtifactIcon, TeamSearch } from "@/components/public/team-search"; +import { TeamSearch } from "@/components/public/team-search"; import { GuideImageExport } from "@/components/public/guide-image-export"; import { ExportText } from "@/components/public/export-language"; import { WeaponImageExport } from "@/components/public/weapon-image-export"; import { WeaponDisplayMode, WeaponDisplayModeProvider } from "@/components/public/weapon-display-mode"; import { ArtifactImageExport } from "@/components/public/artifact-image-export"; +import { ArtifactSectionContent, type ArtifactSectionGuide } from "@/components/public/artifact-section-content"; import { GuideExportBranding } from "@/components/public/guide-export-branding"; import { ConstellationImage } from "@/components/public/constellation-image"; import { ConditionSwitcher } from "@/components/public/condition-switcher"; @@ -39,21 +39,9 @@ import { MaterialPoster } from "@/components/public/material-poster"; import { RichTextContent } from "@/components/rich-text-content"; import { LiveRefresh } from "@/components/live-refresh"; import { rarityGradientClass, rarityTextClass } from "@/lib/catalog/rarity"; -import { elementIconFilter } from "@/lib/catalog/element"; import type { PublicGuide } from "@/lib/guides/queries"; -import { artifactTierCount } from "@/lib/guides/artifacts"; import { navigationIconPath } from "@/lib/navigation"; -import { - normalizeTargetStat, - recommendedStatElement, - recommendedStatIconName, - statPriorityColorClass, - targetStatIconName, -} from "@/lib/guides/stats"; -import { - normalizeTalentPriorities, - talentRelationSymbol, -} from "@/lib/guides/talents"; +import { filterPublicTeamGroups, type PublicGuideFilters } from "@/lib/guides/public-filters"; import { parseYouTubeUrl } from "@/lib/guides/youtube"; import { normalizeExtraRichText } from "@/lib/rich-text"; import { publicGuideSections } from "@/lib/guides/public-sections"; @@ -75,10 +63,12 @@ export function StructuredGuideShell({ guide, section, preview = false, + filters, }: { guide: PublicGuide; section?: string; preview?: boolean; + filters?: PublicGuideFilters; }) { const sections = publicSections(guide); const baseHref = preview ? `/admin/${guide.characterKey}/preview` : `/${guide.slug}`; @@ -173,7 +163,7 @@ export function StructuredGuideShell({
{section ? ( - + ) : ( )} @@ -340,15 +330,19 @@ function GuideNavigation({ function GuideSection({ guide, section, + filters, + preview, }: { guide: PublicGuide; section: string; + filters?: PublicGuideFilters; + preview: boolean; }) { - if (section === "weapon") return ; - if (section === "artifact") return ; + if (section === "weapon") return ; + if (section === "artifact") return ; if (section === "constellations") - return ; - if (section === "team") return ; + return ; + if (section === "team") return ; const extra = guide.extras.find((item) => item.slug === section); return extra ? : null; } @@ -371,19 +365,21 @@ function lunarisItemUrl( return `https://lunaris.moe/${path}/${encodeURIComponent(key)}`; } -function WeaponSection({ guide }: { guide: PublicGuide }) { - return ; +function WeaponSection({ guide, filters }: { guide: PublicGuide; filters?: PublicGuideFilters }) { + return ; } function WeaponSectionContent({ guide, exporting = false, + filters, groups: providedGroups, conditionNumber, showExportActions = false, }: { guide: PublicGuide; exporting?: boolean; + filters?: PublicGuideFilters; groups?: PublicGuide["weaponGroups"]; conditionNumber?: number; showExportActions?: boolean; @@ -391,9 +387,11 @@ function WeaponSectionContent({ const note = guide.sections.find( (section) => section.slug === "weapon", )?.note; - const weaponGroups = (providedGroups ?? guide.weaponGroups).filter( - (group) => group.rows.length > 0, - ); + const weaponGroups = (providedGroups ?? guide.weaponGroups).filter((group) => group.rows.length > 0); + const selectedGroup = weaponGroups[filters?.conditionIndex ?? 0]; + const groupsToRender = filters + ? selectedGroup ? [[filters.conditionIndex, selectedGroup] as const] : [] + : weaponGroups.map((group, index) => [index, group] as const); const useCompactExportGrid = exporting && weaponGroups.reduce((total, group) => total + group.rows.length, 0) > 6; @@ -444,11 +442,13 @@ function WeaponSectionContent({ group.name || (exporting ? `Condition ${index + 1}` : `เงื่อนไข ${index + 1}`))} + selectedIndex={filters?.conditionIndex} + queryParam={filters ? "condition" : undefined} > - {weaponGroups.map((group, groupIndex, groups) => ( + {groupsToRender.map(([groupIndex, group]) => (
- {groups.length > 1 || conditionNumber ? ( + {weaponGroups.length > 1 || conditionNumber ? (

{group.name || (exporting ? `Condition ${conditionNumber ?? groupIndex + 1}` : `เงื่อนไข ${conditionNumber ?? groupIndex + 1}`)}

@@ -611,548 +611,77 @@ function WeaponSectionContent({ ); } -function ArtifactSection({ guide }: { guide: PublicGuide }) { - const targetConditions = guide.artifactProfile?.targetConditions.filter( - (group) => group.rows.length, - ) ?? []; +function ArtifactSection({ + guide, + filters, + preview, +}: { + guide: PublicGuide; + filters?: PublicGuideFilters; + preview: boolean; +}) { + const profile = guide.artifactProfile; + const recommendedConditions = profile?.recommendedConditions ?? []; + const targetConditions = profile?.targetConditions.filter((group) => group.rows.length) ?? []; + const selectedGuide: ArtifactSectionGuide = filters ? { + name: guide.name, + coverUrl: guide.coverUrl, + character: { + splashImageUrl: guide.character.splashImageUrl, + talents: guide.character.talents, + }, + elementIconUrls: guide.elementIconUrls, + artifactProfile: profile ? { + ...profile, + recommendedConditions: recommendedConditions[filters.recommendedIndex] + ? [recommendedConditions[filters.recommendedIndex]] + : [], + targetConditions: targetConditions[filters.targetsIndex] + ? [targetConditions[filters.targetsIndex]] + : [], + } : null, + artifactGroups: guide.artifactGroups[filters.setsIndex] + ? [guide.artifactGroups[filters.setsIndex]] + : [], + } : guide; + const selection = filters ? { + recommendedIndex: filters.recommendedIndex, + setsIndex: filters.setsIndex, + targetsIndex: filters.targetsIndex, + labels: { + recommended: filters.recommendedLabels, + sets: filters.setsLabels, + targets: filters.targetsLabels, + }, + } : undefined; return ( group.name || `เงื่อนไข ${index + 1}`)} - targetConditionLabels={targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} - tierConditionLabels={guide.artifactGroups.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} - > - - + initialData={preview ? guide : undefined} + recommendedConditionLabels={filters?.recommendedLabels ?? recommendedConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} + targetConditionLabels={filters?.targetsLabels ?? targetConditions.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} + tierConditionLabels={filters?.setsLabels ?? guide.artifactGroups.map((group, index) => group.name || `เงื่อนไข ${index + 1}`)} + /> } /> ); } -function ArtifactSectionContent({ - guide, - action, - exporting = false, -}: { - guide: PublicGuide; - action?: ReactNode; - exporting?: boolean; -}) { - const profile = guide.artifactProfile; - const recommendedConditions = profile?.recommendedConditions ?? []; - const recommendedCards = recommendedConditions.map((condition, conditionIndex) => ( -
- {exporting && recommendedConditions.length > 1 ?

{condition.name || }

: null} - {condition.note ?

{condition.note}

: null} - {profile ? ( - - - {exportLabel(exporting, "ออฟหลักที่ดีที่สุด", "Best Main Stats")} - - - - - - - - ) : null} - {condition.substats.length ? ( - - - {exportLabel(exporting, "ออฟรองที่ดีที่สุด", "Best Substats")} -
- {condition.substats.map((stat, index) => ( - - ))} -
-
-
- ) : null} - -
- )); - - return ( -
- {exporting ? ( - - ) : null} -
-
-

- อาร์ติแฟกต์และสแตทที่แนะนำ -

-
- -

- Artifacts & Stats -

-
- {action} -
-
-
- {" "} -
- {exporting ? recommendedCards : ( - condition.name || `เงื่อนไข ${index + 1}`)}> - {recommendedCards} - - )} -
- {(() => { - const tierCards = guide.artifactGroups.map((condition, conditionIndex) => ( -
- {exporting && guide.artifactGroups.length > 1 ?

{condition.name || }

: null} - {condition.note ?

{condition.note}

: null} - {condition.tiers.map((group) => - - - {group.name === "Late Game" - ? exportLabel(exporting, "เลทเกม", "Late Game") - : group.name === "Early Game" - ? exportLabel(exporting, "ต้นเกม", "Early Game") - : group.name} - - - - {group.rows.map((row, index) => ( -
- -
-

- {artifactTierCount(row.second?.name ?? null)}{" "} - {row.first.name} - {row.second ? ` + 2 ${row.second.name}` : ""} -

- {row.note ? ( -

- {row.note} -

- ) : null} -
-

- {Number(row.percent) === 0 ? "-%" : metric(row.percent, exporting ? "en-US" : undefined)} -

-
- ))} -
-
)} -
- )); - return exporting ? tierCards : condition.name || `เงื่อนไข ${index + 1}`)}>{tierCards}; - })()} -
-
- {profile ? ( -
- - - - - {profile.targetConditions.some((group) => group.rows.length) ? ( - exporting ? ( - profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => ( -
-

{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}

- {group.note ?

{group.note}

: null} -
- {group.rows.map((target, index) => ( -
- - {targetStatIconName(target.stat) ? ( - - ) : null} - - {statLabel( - normalizeTargetStat(target.stat) ?? target.stat, exporting, - )} - - - {target.value} -
- ))} -
-
- )) - ) : ( - group.rows.length).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}> - {profile.targetConditions.filter((group) => group.rows.length).map((group, groupIndex) => ( -
-

{exportLabel(exporting, "ค่าสถานะต่ำๆที่ควรจะปั้นไว้", "Recommended Minimum Stats")}

- {group.note ?

{group.note}

: null} -
- {group.rows.map((target, index) => ( -
- - {targetStatIconName(target.stat) ? : null} - {statLabel(normalizeTargetStat(target.stat) ?? target.stat)} - - {target.value} -
- ))} -
-
- ))} -
- ) - ) : null} - {profile.talents.length ? ( -
-

- {exportLabel(exporting, "ลำดับการอัพพรสวรรค์", "Talent Priority")} -

-
- {normalizeTalentPriorities(profile.talents).map( - (talent, index, talents) => { - const catalogTalent = guide.character.talents.find( - (item) => item.type === talent.type, - ); - const talentName = catalogTalent?.name ?? talent.type; - return ( -
-
-
- {catalogTalent?.iconUrl ? ( - {talentName} - ) : ( - - {talent.type} - - )} -
- - {talent.level - ? exportLabel(exporting, `เลเวล ${talent.level}+`, `Level ${talent.level}+`) - : "-"} - -

- {talentName} -

-

- {talent.type === "E" - ? exportLabel(exporting, "สกิลธาตุ", "Elemental Skill") - : talent.type === "Q" - ? exportLabel(exporting, "ท่าไม้ตาย", "Elemental Burst") - : exportLabel(exporting, "ตีปกติ", "Normal Attack")} -

-
- {index < talents.length - 1 ? ( - - {talentRelationSymbol(talent.relationToNext)} - - ) : null} -
- ); - }, - )} -
-
- ) : null} - {profile.note ? ( -
-

{exportLabel(exporting, "โน็ต", "Notes")}

-

- {profile.note} -

-
- ) : null} -
-
-
- ) : null} -
-
- ); -} - -const thaiStats: 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%": "โบนัสการรักษา", -}; - -const englishStats: Record = { - HP: "HP", - ATK: "ATK", - DEF: "DEF", - EM: "Elemental Mastery", - ER: "Energy Recharge", - "CRIT RATE": "CRIT Rate", - "CRIT DAMAGE": "CRIT DMG", - "CRIT Rate/CRIT DMG": "CRIT Rate / CRIT DMG", -}; - -const statLabelAliases: Record = { - "CRIT Rate%": "CRIT RATE", - "CRIT DMG%": "CRIT DAMAGE", - "CRIT Rate%/CRIT DMG%": "CRIT Rate/CRIT DMG", -}; - -function statLabel(value: string, exporting = false): ReactNode { - const labelKey = statLabelAliases[value] ?? value; - if (!exporting) return thaiStats[labelKey] ?? value; - return ( - - ); -} - -function ArtifactStat({ - iconSrc, - label, - values, - exporting = false, - elementIconUrls, -}: { - iconSrc: string; - label: ReactNode; - values: string[]; - exporting?: boolean; - elementIconUrls: Record; -}) { - return ( -
- -

{label}

- -
- {values.length ? ( - values.map((value, index) => ( - - )) - ) : ( - - - )} -
-
- ); -} - -function ArtifactStatValue({ - value, - index, - exporting = false, - elementIconUrls, -}: { - value: string; - index: number; - exporting?: boolean; - elementIconUrls: Record; -}) { - const iconName = recommendedStatIconName(value); - const element = recommendedStatElement(value); - const elementIconUrl = element ? elementIconUrls[element] : null; - const hasIcon = Boolean(iconName); - return ( - - {elementIconUrl ? ( - - ) : hasIcon ? ( - - ) : null} - {statLabel(value, exporting)} - - ); -} - -function ConstellationSection({ guide }: { guide: PublicGuide }) { +function ConstellationSection({ guide, filters }: { guide: PublicGuide; filters?: PublicGuideFilters }) { const note = guide.sections.find( (section) => section.slug === "constellations", )?.note; + const groups = guide.constellationGroups.filter((group) => group.rows.length > 0); + const selectedGroup = groups[filters?.conditionIndex ?? 0]; + const groupsToRender = filters + ? selectedGroup ? [[filters.conditionIndex, selectedGroup] as const] : [] + : groups.map((group, index) => [index, group] as const); return (
@@ -1178,8 +707,8 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) {

) : null}
- group.rows.length > 0).map((group, index) => group.name || `เงื่อนไข ${index + 1}`)}> - {guide.constellationGroups.filter((group) => group.rows.length > 0).map((group, groupIndex, groups) => ( + group.name || `เงื่อนไข ${index + 1}`)} selectedIndex={filters?.conditionIndex} queryParam={filters ? "condition" : undefined}> + {groupsToRender.map(([groupIndex, group]) => (
@@ -1238,14 +773,25 @@ function ConstellationSection({ guide }: { guide: PublicGuide }) { ); } -function TeamSection({ guide }: { guide: PublicGuide }) { +function TeamSection({ guide, filters }: { guide: PublicGuide; filters?: PublicGuideFilters }) { + const groups = filters ? filterPublicTeamGroups(guide, filters) : guide.teamGroups; + const serverFilters = filters ? { + conditionIndex: filters.conditionIndex, + query: filters.query, + field: filters.field, + sort: filters.sort, + } : undefined; return (

คำนวณความเสียหาย

Team Calculation

- +
); } diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx index df309fd..bb711bd 100644 --- a/components/public/team-search.tsx +++ b/components/public/team-search.tsx @@ -4,7 +4,8 @@ import Image from "next/image"; import Link from "next/link"; import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react"; -import { useMemo, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { GuideImageExport } from "@/components/public/guide-image-export"; import { ExportText } from "@/components/public/export-language"; @@ -19,7 +20,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { elementBackgroundColor } from "@/lib/catalog/element"; import { rarityGradientClass } from "@/lib/catalog/rarity"; import { isCustomTravelerKey } from "@/lib/catalog/travelers"; -import type { PublicGuide } from "@/lib/guides/queries"; +import type { PublicTeamCardData, PublicTeamConditionGroup } from "@/lib/guides/public-filters"; import { searchTeams, sortTeamsByDps, @@ -28,7 +29,7 @@ import { } from "@/lib/guides/team-search"; import { cn } from "@/lib/utils"; -type Team = PublicGuide["teams"][number]; +type Team = PublicTeamCardData; type TeamArtifact = { key: string; name: string; @@ -379,32 +380,109 @@ function TeamCard({ ); } -export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) { +export function TeamSearch({ + groups, + serverFilters, +}: { + groups: PublicTeamConditionGroup[]; + serverFilters?: { + conditionIndex: number; + query: string; + field: TeamSearchTarget; + sort: DpsOrder; + }; +}) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const searchString = searchParams.toString(); const [activeCondition, setActiveCondition] = useState("0"); - const activeGroup = groups[Number(activeCondition)] ?? groups[0]; + const activeConditionIndex = serverFilters?.conditionIndex ?? Number(activeCondition); + const activeGroup = groups[activeConditionIndex] ?? groups[0]; const teams = useMemo(() => activeGroup?.teams ?? [], [activeGroup]); - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(serverFilters?.query ?? ""); const [searchTarget, setSearchTarget] = useState("all"); const [dpsOrder, setDpsOrder] = useState(null); - const search = useMemo( - () => searchTeams(teams, (team) => ({ - team: [team.title], - character: team.members.flatMap((member) => [member.character.name, ...member.character.aliases]), - weapon: team.members.flatMap((member) => member.weapon ? [member.weapon.name, ...member.weapon.aliases] : []), - artifact: team.members.flatMap((member) => [member.artifact, member.secondArtifact].flatMap((artifact) => artifact ? [artifact.name, ...artifact.aliases] : [])), - }), query, searchTarget), - [query, searchTarget, teams], - ); + const currentQuery = serverFilters?.query ?? ""; + const currentField = serverFilters?.field ?? searchTarget; + const currentSort = serverFilters?.sort ?? dpsOrder; - const filteredTeams = sortTeamsByDps(search.items, dpsOrder); + useEffect(() => { + if (!serverFilters) return; + const nextQuery = query.trim(); + if (nextQuery === currentQuery) return; + const timeout = window.setTimeout(() => { + const nextParams = new URLSearchParams(searchString); + if (nextQuery) nextParams.set("q", nextQuery); + else nextParams.delete("q"); + const queryString = nextParams.toString(); + router.replace(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false }); + }, 300); + return () => window.clearTimeout(timeout); + }, [currentQuery, pathname, query, router, searchString, serverFilters]); + + const localSearch = useMemo( + () => serverFilters ? null : searchTeams(teams, (team) => ({ + team: [team.title], + character: team.members.flatMap((member) => [member.character.name, ...(member.character.aliases ?? [])]), + weapon: team.members.flatMap((member) => member.weapon ? [member.weapon.name, ...(member.weapon.aliases ?? [])] : []), + artifact: team.members.flatMap((member) => [member.artifact, member.secondArtifact].flatMap((artifact) => artifact ? [artifact.name, ...(artifact.aliases ?? [])] : [])), + }), query, currentField), + [currentField, query, serverFilters, teams], + ); + const filteredTeams = serverFilters + ? teams + : sortTeamsByDps(localSearch?.items ?? [], currentSort); + + function updateUrl(name: string, value: string | null) { + const nextParams = new URLSearchParams(searchString); + const nextQuery = query.trim(); + if (nextQuery) nextParams.set("q", nextQuery); + else nextParams.delete("q"); + if (value === null) nextParams.delete(name); + else nextParams.set(name, value); + const queryString = nextParams.toString(); + router.push(`${pathname}${queryString ? `?${queryString}` : ""}`, { scroll: false }); + } + + function selectCondition(index: number) { + if (!serverFilters) { + setActiveCondition(String(index)); + return; + } + updateUrl("condition", index === 0 ? null : String(index + 1)); + } + + function selectField(field: TeamSearchTarget) { + if (!serverFilters) { + setSearchTarget(field); + return; + } + updateUrl("field", field === "all" ? null : field); + } + + function selectSort(order: Exclude) { + if (!serverFilters) { + setDpsOrder(order); + return; + } + updateUrl("sort", order); + } + + function clearSort() { + if (!serverFilters) { + setDpsOrder(null); + return; + } + updateUrl("sort", null); + } return (
- {groups.length > 1 ? ( - { - if (values[0]) setActiveCondition(values[0]); + { + if (values[0]) selectCondition(Number(values[0])); }}> {groups.map((group, index) => {group.name || `เงื่อนไข ${index + 1}`})} @@ -426,10 +504,10 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) { className="max-w-full flex-wrap justify-start" variant="outline" size="sm" - value={[searchTarget]} + value={[currentField]} onValueChange={(value) => { - const target = value[0] as TeamSearchTarget | undefined; - if (target) setSearchTarget(target); + const field = value[0] as TeamSearchTarget | undefined; + if (field) selectField(field); }} > ทั้งหมด @@ -443,10 +521,10 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) { aria-label="เรียงทีมตาม DPS" variant="outline" size="sm" - value={dpsOrder ? [dpsOrder] : []} + value={currentSort ? [currentSort] : []} onValueChange={(value) => { const order = value[0] as Exclude | undefined; - if (order) setDpsOrder(order); + if (order) selectSort(order); }} > Best DPS @@ -455,8 +533,8 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {