feat : add elemental ickn
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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) } })) })),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user