feat : add elemental ickn

This commit is contained in:
2026-09-04 18:30:22 +07:00 Unverified
parent 9a7954fa36
commit 9d3717d0f3
7 changed files with 129 additions and 25 deletions
+57 -13
View File
@@ -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<string, string>;
}) {
return (
<Field>
@@ -409,7 +415,7 @@ function StatSelect({
}}
>
<SelectTrigger className="w-full">
<StatIcons values={value} />
<StatIcons values={value} elementIconUrls={elementIconUrls} />
<SelectValue placeholder="Select recommended stats…" />
</SelectTrigger>
<SelectContent>
@@ -420,7 +426,7 @@ function StatSelect({
value={option}
disabled={value.length >= max && !value.includes(option)}
>
<StatIcons values={[option]} />
<StatIcons values={[option]} elementIconUrls={elementIconUrls} />
{option}
</SelectItem>
))}
@@ -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<string, string>;
}) {
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 (
<span className="flex shrink-0 items-center gap-0.5" aria-hidden="true">
{iconNames.map((iconName) => (
<StatIcon key={iconName} iconName={iconName} />
{icons.map((icon) => (
<StatIcon key={icon.name} {...icon} />
))}
</span>
);
}
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 (
<PlaceholderIcon
className="size-5 text-muted-foreground"
@@ -463,11 +494,12 @@ function StatIcon({ iconName }: { iconName: string }) {
}
return (
<Image
src={`/icon/stats/${iconName}.webp`}
src={src}
alt=""
width={20}
height={20}
className="size-5 object-contain brightness-0 invert"
className={cn("size-5 object-contain", !element && "brightness-0 invert")}
style={element ? { filter: elementIconFilter(element) } : undefined}
onError={() => 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}
/>
<StatSelect
label="Goblet / ถ้วย"
@@ -1584,6 +1625,7 @@ function ArtifactEditor({
value={goblet}
max={3}
onChange={setGoblet}
elementIconUrls={elementIconUrls}
/>
<StatSelect
label="Circlet / หมวก"
@@ -1591,6 +1633,7 @@ function ArtifactEditor({
value={circlet}
max={3}
onChange={setCirclet}
elementIconUrls={elementIconUrls}
/>
</div>
<StatSelect
@@ -1599,6 +1642,7 @@ function ArtifactEditor({
value={substats}
max={substatOptions.length}
onChange={setSubstats}
elementIconUrls={elementIconUrls}
/>
<FieldSet>
<FieldLegend>Target Stats</FieldLegend>
+2 -10
View File
@@ -1,14 +1,6 @@
import Image from "next/image";
const ELEMENT_FILTERS: Record<string, string> = {
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) }}
/>
);
}
+24 -1
View File
@@ -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}
/>
<ArtifactStat
iconSrc="/icon/Icon_Goblet_of_Eonothem.webp"
label="ถ้วย"
values={profile.goblet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
<ArtifactStat
iconSrc="/icon/Icon_Circlet_of_Logos.webp"
label="หัว"
values={profile.circlet}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
</CardContent>
</Card>
@@ -531,6 +536,7 @@ function ArtifactSectionContent({
value={stat}
index={index}
exporting={exporting}
elementIconUrls={guide.elementIconUrls}
/>
))}
</div>
@@ -804,11 +810,13 @@ function ArtifactStat({
label,
values,
exporting = false,
elementIconUrls,
}: {
iconSrc: string;
label: string;
values: string[];
exporting?: boolean;
elementIconUrls: Record<string, string>;
}) {
return (
<div
@@ -842,6 +850,7 @@ function ArtifactStat({
value={value}
index={index}
exporting={exporting}
elementIconUrls={elementIconUrls}
/>
))
) : (
@@ -856,12 +865,16 @@ function ArtifactStatValue({
value,
index,
exporting = false,
elementIconUrls,
}: {
value: string;
index: number;
exporting?: boolean;
elementIconUrls: Record<string, string>;
}) {
const iconName = recommendedStatIconName(value);
const element = recommendedStatElement(value);
const elementIconUrl = element ? elementIconUrls[element] : null;
const hasIcon = Boolean(iconName);
return (
<span
@@ -870,7 +883,17 @@ function ArtifactStatValue({
statPriorityColorClass(index),
)}
>
{hasIcon ? (
{elementIconUrl ? (
<Image
src={elementIconUrl}
alt=""
width={20}
height={20}
loading={exporting ? "eager" : undefined}
className="size-5 shrink-0 object-contain"
style={{ filter: elementIconFilter(element!) }}
/>
) : hasIcon ? (
<Image
src={`/icon/stats/${iconName}.${iconName === "elemental-bonus" ? "svg" : "webp"}`}
alt=""
+14
View File
@@ -8,6 +8,20 @@ const elementBackgroundColors: Record<string, string> = {
Geo: "#0F0D00",
};
const elementIconFilters: Record<string, string> = {
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];
}
+16
View File
@@ -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) } })) })),
+11 -1
View File
@@ -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 };
+5
View File
@@ -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"