From 9d3717d0f38d4cd65cb2cd12afffc79440a52313 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Fri, 4 Sep 2026 18:30:22 +0700 Subject: [PATCH] feat : add elemental ickn --- components/admin/guide-section-editor.tsx | 70 ++++++++++++++++++----- components/public/element-icon.tsx | 12 +--- components/public/structured-guide.tsx | 25 +++++++- lib/catalog/element.ts | 14 +++++ lib/guides/queries.ts | 16 ++++++ lib/guides/schemas.test.ts | 12 +++- lib/guides/stats.ts | 5 ++ 7 files changed, 129 insertions(+), 25 deletions(-) diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index de945fc..2faac94 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -116,11 +116,13 @@ import { } from "@/lib/guides/schemas"; import { normalizeTargetStat, + recommendedStatElement, recommendedStatIconName, targetStatDefaults, targetStatIconName, targetStatOptions, } from "@/lib/guides/stats"; +import { elementIconFilter } from "@/lib/catalog/element"; import { supportsWeaponType } from "@/lib/catalog/lunaris"; import { rarityGradientClass } from "@/lib/catalog/rarity"; import { @@ -158,6 +160,8 @@ type CatalogItem = { imageUrl: string; rarity?: number | null; weaponType?: string | null; + element?: string | null; + elementIconUrl?: string | null; }; const teamMemberApplyOptions = [ ["characterKey", "Character"], @@ -391,12 +395,14 @@ function StatSelect({ value, max, onChange, + elementIconUrls, }: { label: string; options: readonly string[]; value: string[]; max: number; onChange: (value: string[]) => void; + elementIconUrls: Record; }) { return ( @@ -409,7 +415,7 @@ function StatSelect({ }} > - + @@ -420,7 +426,7 @@ function StatSelect({ value={option} disabled={value.length >= max && !value.includes(option)} > - + {option} ))} @@ -432,28 +438,53 @@ function StatSelect({ ); } -function StatIcons({ values }: { values: readonly string[] }) { - const iconNames = values.flatMap((value) => { +function StatIcons({ + values, + elementIconUrls = {}, +}: { + values: readonly string[]; + elementIconUrls?: Record; +}) { + const icons = values.flatMap((value) => { + const element = recommendedStatElement(value); + if (element && elementIconUrls[element]) { + return [{ name: element, src: elementIconUrls[element], element }]; + } const targetIcon = targetStatIconName(value); - if (targetIcon) return [targetIcon]; + if (targetIcon) { + return [{ name: targetIcon, src: `/icon/stats/${targetIcon}.webp` }]; + } const recommendedIcon = recommendedStatIconName(value); - return recommendedIcon ? [recommendedIcon] : []; + return recommendedIcon + ? [{ + name: recommendedIcon, + src: `/icon/stats/${recommendedIcon}.${recommendedIcon === "elemental-bonus" ? "svg" : "webp"}`, + }] + : []; }); - if (!iconNames.length) return null; + if (!icons.length) return null; return ( ); } -function StatIcon({ iconName }: { iconName: string }) { +function StatIcon({ + name, + src, + element, +}: { + name: string; + src: string; + element?: string; +}) { const [hasError, setHasError] = useState(false); if (hasError) { const PlaceholderIcon = - iconName === "healing-bonus" ? HeartPulseIcon : SparklesIcon; + name === "healing-bonus" ? HeartPulseIcon : SparklesIcon; return ( setHasError(true)} /> ); @@ -1130,8 +1162,16 @@ function ArtifactEditor({ artifacts, initial, catalogTalents, + characters, }: EditorBase) { const profile = initial.artifactProfile; + const elementIconUrls = Object.fromEntries( + characters.flatMap((character) => + character.element && character.elementIconUrl + ? [[character.element, character.elementIconUrl] as const] + : [], + ), + ); const artifactSensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -1577,6 +1617,7 @@ function ArtifactEditor({ value={sands} max={3} onChange={setSands} + elementIconUrls={elementIconUrls} />
Target Stats diff --git a/components/public/element-icon.tsx b/components/public/element-icon.tsx index ff18b99..eea8058 100644 --- a/components/public/element-icon.tsx +++ b/components/public/element-icon.tsx @@ -1,14 +1,6 @@ import Image from "next/image"; -const ELEMENT_FILTERS: Record = { - Anemo: "brightness(0) saturate(100%) invert(76%) sepia(19%) saturate(867%) hue-rotate(113deg) brightness(92%) contrast(86%)", - Geo: "brightness(0) saturate(100%) invert(77%) sepia(80%) saturate(1159%) hue-rotate(337deg) brightness(99%) contrast(90%)", - Electro: "brightness(0) saturate(100%) invert(66%) sepia(17%) saturate(1525%) hue-rotate(225deg) brightness(91%) contrast(89%)", - Dendro: "brightness(0) saturate(100%) invert(70%) sepia(39%) saturate(713%) hue-rotate(32deg) brightness(93%) contrast(87%)", - Hydro: "brightness(0) saturate(100%) invert(71%) sepia(68%) saturate(1329%) hue-rotate(164deg) brightness(97%) contrast(95%)", - Pyro: "brightness(0) saturate(100%) invert(54%) sepia(72%) saturate(1836%) hue-rotate(339deg) brightness(99%) contrast(89%)", - Cryo: "brightness(0) saturate(100%) invert(87%) sepia(20%) saturate(952%) hue-rotate(167deg) brightness(93%) contrast(89%)", -}; +import { elementIconFilter } from "@/lib/catalog/element"; export function ElementIcon({ element, url }: { element: string; url: string }) { return ( @@ -18,7 +10,7 @@ export function ElementIcon({ element, url }: { element: string; url: string }) width={16} height={16} className="shrink-0" - style={{ filter: ELEMENT_FILTERS[element] }} + style={{ filter: elementIconFilter(element) }} /> ); } diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index 2335001..fc45650 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -20,11 +20,13 @@ import { GuideImageExport } from "@/components/public/guide-image-export"; import { ConstellationImage } from "@/components/public/constellation-image"; 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, @@ -504,18 +506,21 @@ function ArtifactSectionContent({ label="นาฬิกา" values={profile.sands} exporting={exporting} + elementIconUrls={guide.elementIconUrls} /> @@ -531,6 +536,7 @@ function ArtifactSectionContent({ value={stat} index={index} exporting={exporting} + elementIconUrls={guide.elementIconUrls} /> ))} @@ -804,11 +810,13 @@ function ArtifactStat({ label, values, exporting = false, + elementIconUrls, }: { iconSrc: string; label: string; values: string[]; exporting?: boolean; + elementIconUrls: Record; }) { return (
)) ) : ( @@ -856,12 +865,16 @@ 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 ( - {hasIcon ? ( + {elementIconUrl ? ( + + ) : hasIcon ? ( = { Geo: "#0F0D00", }; +const elementIconFilters: Record = { + Anemo: "brightness(0) saturate(100%) invert(76%) sepia(19%) saturate(867%) hue-rotate(113deg) brightness(92%) contrast(86%)", + Geo: "brightness(0) saturate(100%) invert(77%) sepia(80%) saturate(1159%) hue-rotate(337deg) brightness(99%) contrast(90%)", + Electro: "brightness(0) saturate(100%) invert(66%) sepia(17%) saturate(1525%) hue-rotate(225deg) brightness(91%) contrast(89%)", + Dendro: "brightness(0) saturate(100%) invert(70%) sepia(39%) saturate(713%) hue-rotate(32deg) brightness(93%) contrast(87%)", + Hydro: "brightness(0) saturate(100%) invert(71%) sepia(68%) saturate(1329%) hue-rotate(164deg) brightness(97%) contrast(95%)", + Pyro: "brightness(0) saturate(100%) invert(54%) sepia(72%) saturate(1836%) hue-rotate(339deg) brightness(99%) contrast(89%)", + Cryo: "brightness(0) saturate(100%) invert(87%) sepia(20%) saturate(952%) hue-rotate(167deg) brightness(93%) contrast(89%)", +}; + export function elementBackgroundColor(element: string | null | undefined): string { return (element && elementBackgroundColors[element]) || "#17383d"; } + +export function elementIconFilter(element: string): string | undefined { + return elementIconFilters[element]; +} diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index 98c208b..c328cea 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -104,6 +104,21 @@ export async function getPublicGuide(slug: string) { getDb().select().from(teamCompositions).where(eq(teamCompositions.guideId, row.guide.id)).orderBy(asc(teamCompositions.sortOrder)), ]); + const elementIconUrls = Object.fromEntries( + (await getDb() + .select({ + element: catalogCharacters.element, + elementIconKey: catalogCharacters.elementIconKey, + }) + .from(catalogCharacters) + .where(isNotNull(catalogCharacters.elementIconKey))) + .flatMap((character) => + character.element && character.elementIconKey + ? [[character.element, publicMediaUrl(character.elementIconKey)] as const] + : [], + ), + ); + const weaponRows = weaponConditionsRows.length ? await getDb().select({ recommendation: weaponRecommendations, weapon: catalogWeapons }).from(weaponRecommendations).innerJoin(catalogWeapons, eq(catalogWeapons.key, weaponRecommendations.weaponKey)).where(inArray(weaponRecommendations.conditionId, weaponConditionsRows.map((condition) => condition.id))).orderBy(asc(weaponRecommendations.sortOrder)) : []; const constellations = constellationGroups.length ? await getDb().select().from(constellationRecommendations).where(inArray(constellationRecommendations.conditionId, constellationGroups.map((group) => group.id))).orderBy(asc(constellationRecommendations.level)) : []; @@ -124,6 +139,7 @@ export async function getPublicGuide(slug: string) { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), character: catalogCharacterWithUrls(row.character), + elementIconUrls, sections, weapons: weaponRows.map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })), weaponGroups: weaponConditionsRows.map((condition) => ({ ...condition, rows: weaponRows.filter(({ recommendation }) => recommendation.conditionId === condition.id).map(({ recommendation, weapon }) => ({ ...recommendation, weapon: { ...weapon, imageUrl: publicMediaUrl(weapon.imageKey) } })) })), diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts index dbe202f..5b89b77 100644 --- a/lib/guides/schemas.test.ts +++ b/lib/guides/schemas.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { calculateTeamMetrics } from "@/lib/guides/calculations"; import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas"; -import { recommendedStatIconName, targetStatIconName } from "@/lib/guides/stats"; +import { + recommendedStatElement, + recommendedStatIconName, + targetStatIconName, +} from "@/lib/guides/stats"; describe("structured guide validation", () => { const artifactGroups = [ @@ -63,6 +67,12 @@ describe("structured guide validation", () => { expect(targetStatIconName("DEF")).toBe(recommendedStatIconName("DEF%")); }); + it("maps elemental damage recommendations to Lunaris element icons", () => { + expect(recommendedStatElement("Pyro DMG%")).toBe("Pyro"); + expect(recommendedStatElement("Hydro DMG%")).toBe("Hydro"); + expect(recommendedStatElement("Physical DMG%")).toBeNull(); + }); + it("limits an optional note on each artifact recommendation", () => { const row = { firstArtifactKey: "1", secondArtifactKey: null, percent: 100 }; const base = { expectedVersion: 1, sands: [], goblet: [], circlet: [], substats: [], targets: [], talents: [], note: null }; diff --git a/lib/guides/stats.ts b/lib/guides/stats.ts index ade08a6..229b281 100644 --- a/lib/guides/stats.ts +++ b/lib/guides/stats.ts @@ -51,6 +51,11 @@ export function recommendedStatIconName(value: string): string | null { return ["hp", "atk", "def", "em", "er"].includes(base) ? base : null; } +export function recommendedStatElement(value: string): string | null { + const match = /^(Anemo|Geo|Electro|Dendro|Hydro|Pyro|Cryo) DMG%$/u.exec(value); + return match?.[1] ?? null; +} + export function statPriorityColorClass(index: number): string { return index === 0 ? "text-tier-gold"