feat: enhance locale handling and improve cookie management in Header component
Gitea Auto Deploy / Deploy-Container (push) Successful in 50s

fix: update CopyImport component to handle profile selection and data validation

feat: refactor EnkaImport component for better data parsing and error handling

fix: improve FreeSRImport component with enhanced data validation and error management

fix: update calcData helper functions to handle edge cases and improve robustness

refactor: enhance converter functions for better data handling and validation

feat: add validation to persisted stores using Zod schemas

fix: update Zustand stores to use validated JSON storage for improved data integrity

chore: add new Zod schemas for locale and connection persistence

fix: ensure proper handling of optional fields in various data structures
This commit is contained in:
2026-05-15 20:34:15 +07:00
parent 93a8d8f1ac
commit ca61ddfd5f
26 changed files with 556 additions and 304 deletions
+3 -1
View File
@@ -79,6 +79,8 @@ export default function Header() {
if (cookieLocale) {
if (!listCurrentLanguageApi.hasOwnProperty(cookieLocale)) {
setLocale("en")
document.cookie = "MYNEXTAPP_LOCALE=en;"
router.refresh()
} else {
setLocale(cookieLocale)
}
@@ -614,4 +616,4 @@ export default function Header() {
))}
</div>
)
}
}
+24 -9
View File
@@ -71,8 +71,11 @@ export default function CopyImport() {
const selectAll = () => {
if (avatarCopySelected) {
setSelectedProfiles(avatars[avatarCopySelected?.ID.toString()].profileList.map((profile, index) => {
if (!profile.lightcone?.item_id && Object.keys(profile.relics).length == 0) {
const sourceAvatar = avatars[avatarCopySelected.ID.toString()]
const sourceProfiles = sourceAvatar?.profileList ?? []
setSelectedProfiles(sourceProfiles.map((profile, index) => {
if (!profile.lightcone?.item_id && Object.keys(profile.relics ?? {}).length == 0) {
return null;
}
return {
@@ -110,8 +113,20 @@ export default function CopyImport() {
return;
}
const newListProfile = avatars[avatarCopySelected.ID.toString()].profileList.map((profile) => {
if (!profile.lightcone?.item_id && Object.keys(profile.relics).length == 0) {
const sourceAvatar = avatars[avatarCopySelected.ID.toString()]
const targetAvatar = avatars[avatarSelected.ID.toString()]
if (!sourceAvatar || !targetAvatar) {
setMessage({
type: "error",
text: transI18n("noDataToImport")
});
return;
}
const selectedKeys = new Set(selectedProfiles.map((profile) => profile.key))
const newListProfile = sourceAvatar.profileList.map((profile, index) => {
if (!selectedKeys.has(index) || (!profile.lightcone?.item_id && Object.keys(profile.relics ?? {}).length == 0)) {
return null;
}
return {
@@ -121,9 +136,9 @@ export default function CopyImport() {
}).filter((profile) => profile !== null);
const newAvatar = {
...avatars[avatarSelected?.ID?.toString()],
profileList: avatars[avatarSelected?.ID?.toString()].profileList.concat(newListProfile),
profileSelect: avatars[avatarSelected?.ID?.toString()].profileList.length - 1,
...targetAvatar,
profileList: targetAvatar.profileList.concat(newListProfile),
profileSelect: targetAvatar.profileList.length + newListProfile.length - 1,
}
setAvatar(newAvatar);
setSelectedProfiles([]);
@@ -275,7 +290,7 @@ export default function CopyImport() {
{/* Character Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{avatarCopySelected && avatars[avatarCopySelected?.ID.toString()]?.profileList.map((profile, index) => {
if (!profile.lightcone?.item_id && Object.keys(profile.relics).length == 0) {
if (!profile.lightcone?.item_id && Object.keys(profile.relics ?? {}).length == 0) {
return null;
}
return (
@@ -291,4 +306,4 @@ export default function CopyImport() {
</div>
</div>
)
}
}
+53 -63
View File
@@ -9,6 +9,28 @@ import { converterOneEnkaDataToAvatarStore } from "@/helper";
import useModelStore from "@/stores/modelStore";
import { toast } from "react-toastify";
import { useTranslations } from "next-intl";
import { avatarEnkaDetailSchema, enkaResponseSchema } from "@/zod";
import { z } from "zod";
import useDetailDataStore from "@/stores/detailDataStore";
type AvatarEnkaDetailInput = z.infer<typeof avatarEnkaDetailSchema>;
const toCharacterInfoCard = (character: AvatarEnkaDetailInput): CharacterInfoCardType => ({
key: character.avatarId,
avatar_id: character.avatarId,
rank: character.rank ?? 0,
level: character.level,
lightcone: {
level: character.equipment?.level ?? 0,
rank: character.equipment?.rank ?? 0,
item_id: character.equipment?.tid ?? 0,
},
relics: character.relicList.map((relic) => ({
level: relic.level,
relic_id: relic.tid,
relic_set_id: parseInt(relic.tid.toString().slice(1, -1), 10),
})),
});
export default function EnkaImport() {
const {
@@ -22,8 +44,10 @@ export default function EnkaImport() {
const transI18n = useTranslations("DataPage")
const { avatars, setAvatar } = useUserDataStore();
const { setIsOpenImport } = useModelStore()
const { mapAvatar } = useDetailDataStore()
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
const validCharacters = enkaData?.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]) ?? []
const handlerFetchData = async () => {
if (!uidInput) {
@@ -31,32 +55,27 @@ export default function EnkaImport() {
return;
}
setIsLoading(true)
const data : EnkaResponse = await SendDataThroughProxy({data: {serverUrl: "https://enka.network/api/hsr/uid/" + uidInput, method: "GET"}})
if (data) {
setEnkaData(data)
setSelectedCharacters(data.detailInfo.avatarDetailList.map((character) => {
return {
key: character.avatarId,
avatar_id: character.avatarId,
rank: character.rank ?? 0,
level: character.level,
lightcone: {
level: character.equipment?.level ?? 0,
rank: character.equipment?.rank ?? 0,
item_id: character.equipment?.tid ?? 0,
},
relics: character.relicList.map((relic) => ({
level: relic.level,
relic_id: relic.tid,
relic_set_id: parseInt(relic.tid.toString().slice(1, -1), 10),
})),
} as CharacterInfoCardType
}))
try {
const data: unknown = await SendDataThroughProxy({data: {serverUrl: "https://enka.network/api/hsr/uid/" + uidInput, method: "GET"}})
const parsed = enkaResponseSchema.safeParse(data)
if (!parsed.success) {
setEnkaData(null)
setSelectedCharacters([])
setError(transI18n("failedToFetchEnkaData"))
return
}
setEnkaData(parsed.data as EnkaResponse)
setSelectedCharacters(parsed.data.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]).map(toCharacterInfoCard))
setError("")
} else {
} catch {
setEnkaData(null)
setSelectedCharacters([])
setError(transI18n("failedToFetchEnkaData"))
} finally {
setIsLoading(false)
}
setIsLoading(false)
}
const handleCharacterToggle = (character: CharacterInfoCardType) => {
@@ -73,26 +92,7 @@ export default function EnkaImport() {
const selectAll = () => {
if (enkaData) {
setSelectedCharacters(enkaData?.detailInfo.avatarDetailList.map((character) => {
return {
key: character.avatarId,
avatar_id: character.avatarId,
rank: character.rank ?? 0,
level: character.level,
lightcone: (character.equipment && character.equipment.tid) ? {
level: character.equipment?.level ?? 0,
rank: character.equipment?.rank ?? 0,
item_id: character.equipment?.tid ?? 0,
} : null,
relics: character.relicList.map((relic) => {
return {
level: relic.level,
relic_id: relic.tid,
relic_set_id: parseInt(relic.tid.toString().slice(1, -1), 10),
}
}),
} as CharacterInfoCardType
}));
setSelectedCharacters(validCharacters.map(toCharacterInfoCard));
}
};
@@ -107,7 +107,13 @@ export default function EnkaImport() {
}
setError("");
const listAvatars = { ...avatars }
const filterData = enkaData.detailInfo.avatarDetailList.filter((character) => selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatarId))
const parsed = enkaResponseSchema.safeParse(enkaData)
if (!parsed.success) {
setError(transI18n("failedToFetchEnkaData"));
return;
}
const filterData = parsed.data.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId] && selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatarId))
filterData.forEach((character) => {
const newAvatar = { ...listAvatars[character.avatarId.toString()] }
if (Object.keys(newAvatar).length !== 0) {
@@ -193,26 +199,10 @@ export default function EnkaImport() {
)}
{/* Character Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{enkaData?.detailInfo.avatarDetailList.map((character) => (
{validCharacters.map((character) => (
<CharacterInfoCard
key={character.avatarId}
character={{
key: character.avatarId,
avatar_id: character.avatarId,
rank: character.rank ?? 0,
level: character.level ?? 0,
lightcone: {
level: character.equipment?.level ?? 0,
rank: character.equipment?.rank ?? 0,
item_id: character.equipment?.tid ?? 0,
},
relics: character.relicList.map((relic) => ({
level: relic.level ?? 0,
relic_id: relic.tid,
relic_set_id: parseInt(relic.tid.toString().slice(1, -1), 10),
})),
} as CharacterInfoCardType
}
character={toCharacterInfoCard(character)}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
@@ -221,4 +211,4 @@ export default function EnkaImport() {
</div>
</div>
);
}
}
+59 -68
View File
@@ -11,6 +11,31 @@ import { toast } from "react-toastify";
import { converterOneFreeSRDataToAvatarStore } from "@/helper";
import { useTranslations } from "next-intl";
import useDetailDataStore from "@/stores/detailDataStore";
import { z } from "zod";
type FreeSRJsonInput = z.infer<typeof freeSrJsonSchema>;
const toCharacterInfoCard = (data: FreeSRJsonInput, character: FreeSRJsonInput["avatars"][string]): CharacterInfoCardType => {
const lightcone = data.lightcones.find((lightcone) => lightcone.equip_avatar === character.avatar_id)
const relics = data.relics.filter((relic) => relic.equip_avatar === character.avatar_id)
return {
key: character.avatar_id,
avatar_id: character.avatar_id,
rank: character.data.rank ?? 0,
level: character.level,
lightcone: {
level: lightcone?.level ?? 0,
rank: lightcone?.rank ?? 0,
item_id: lightcone?.item_id ?? 0,
},
relics: relics.map((relic) => ({
level: relic.level,
relic_id: relic.relic_id,
relic_set_id: relic.relic_set_id,
})),
}
}
export default function FreeSRImport() {
const { avatars, setAvatar } = useUserDataStore();
@@ -20,6 +45,8 @@ export default function FreeSRImport() {
const [Error, setError] = useState("")
const { freeSRData, setFreeSRData, selectedCharacters, setSelectedCharacters } = useFreeSRStore()
const transI18n = useTranslations("DataPage")
const parsedFreeSRData = freeSrJsonSchema.safeParse(freeSRData)
const validFreeSRData = parsedFreeSRData.success ? parsedFreeSRData.data : null
const handleCharacterToggle = (character: CharacterInfoCardType) => {
if (selectedCharacters.some((selectedCharacter) => selectedCharacter.key === character.key)) {
@@ -34,27 +61,9 @@ export default function FreeSRImport() {
};
const selectAll = () => {
if (freeSRData) {
setSelectedCharacters(Object.values(freeSRData?.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
const lightcone = freeSRData.lightcones.find((lightcone) => lightcone.equip_avatar === character.avatar_id)
const relics = freeSRData.relics.filter((relic) => relic.equip_avatar === character.avatar_id)
return {
key: character.avatar_id,
avatar_id: character.avatar_id,
rank: character.data.rank ?? 0,
level: character.level,
lightcone: {
level: lightcone?.level ?? 0,
rank: lightcone?.rank ?? 0,
item_id: lightcone?.item_id ?? "",
},
relics: relics.map((relic) => ({
level: relic.level,
relic_id: relic.relic_id,
relic_set_id: relic.relic_set_id,
})),
} as CharacterInfoCardType
}));
const parsed = freeSrJsonSchema.safeParse(freeSRData)
if (parsed.success) {
setSelectedCharacters(Object.values(parsed.data.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => toCharacterInfoCard(parsed.data, character)));
}
};
@@ -82,41 +91,35 @@ export default function FreeSRImport() {
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
const parsed = freeSrJsonSchema.parse(data)
setFreeSRData(parsed)
const parsed = freeSrJsonSchema.safeParse(data)
if (!parsed.success) {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
return
}
setFreeSRData(parsed.data)
setError("")
setSelectedCharacters(Object.values(parsed?.avatars || {}).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
const lightcone = parsed?.lightcones.find((lightcone) => lightcone.equip_avatar === character.avatar_id)
const relics = parsed?.relics.filter((relic) => relic.equip_avatar === character.avatar_id)
return {
key: character.avatar_id,
avatar_id: character.avatar_id,
rank: character.data.rank ?? 0,
level: character.level,
lightcone: {
level: lightcone?.level ?? 0,
rank: lightcone?.rank ?? 0,
item_id: lightcone?.item_id ?? "",
},
relics: relics?.map((relic) => ({
level: relic.level,
relic_id: relic.relic_id,
relic_set_id: relic.relic_set_id,
})) ?? [],
} as CharacterInfoCardType
}));
setSelectedCharacters(Object.values(parsed.data.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => toCharacterInfoCard(parsed.data, character)));
} catch {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
} finally {
setIsLoading(false)
}
};
reader.onerror = () => {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
setIsLoading(false)
}
reader.readAsText(file);
setIsLoading(false)
}
setIsLoading(false)
};
const handleImport = () => {
@@ -130,8 +133,14 @@ export default function FreeSRImport() {
}
setError("");
const parsed = freeSrJsonSchema.safeParse(freeSRData)
if (!parsed.success) {
setError(transI18n("fileMustBeAValidJsonFile"));
return;
}
const listAvatars = { ...avatars }
const filterData = Object.values(freeSRData?.avatars || {}).filter((character) => selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatar_id))
const filterData = Object.values(parsed.data.avatars).filter((character) => selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatar_id))
filterData.forEach((character) => {
const newAvatar = { ...listAvatars[character.avatar_id] }
if (Object.keys(newAvatar).length !== 0) {
@@ -141,7 +150,7 @@ export default function FreeSRImport() {
rank: character.data.rank ?? 0,
skills: character.data.skills
}
const newProfile = converterOneFreeSRDataToAvatarStore(freeSRData, newAvatar.profileList.length, character.avatar_id)
const newProfile = converterOneFreeSRDataToAvatarStore(parsed.data, newAvatar.profileList.length, character.avatar_id)
if (newProfile) {
newAvatar.profileList.push(newProfile)
newAvatar.profileSelect = newAvatar.profileList.length - 1
@@ -202,29 +211,11 @@ export default function FreeSRImport() {
)}
{/* Character Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Object.values(freeSRData?.avatars || {}).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
const lightcone = freeSRData?.lightcones.find((lightcone) => lightcone.equip_avatar === character.avatar_id)
const relics = freeSRData?.relics.filter((relic) => relic.equip_avatar === character.avatar_id)
{validFreeSRData && Object.values(validFreeSRData.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
return (
<CharacterInfoCard
key={character.avatar_id}
character={{
key: character.avatar_id,
avatar_id: character.avatar_id,
rank: character.data.rank ?? 0,
level: character.level,
lightcone: {
level: lightcone?.level ?? 0,
rank: lightcone?.rank ?? 0,
item_id: lightcone?.item_id ?? "",
},
relics: relics?.map((relic) => ({
level: relic.level,
relic_id: relic.relic_id,
relic_set_id: relic.relic_set_id,
})) ?? [],
} as CharacterInfoCardType
}
character={toCharacterInfoCard(validFreeSRData, character)}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
@@ -234,4 +225,4 @@ export default function FreeSRImport() {
</div>
</div>
)
}
}
+21 -6
View File
@@ -24,7 +24,10 @@ export function calcPromotion(level: number) {
}
export function calcRarity(rarity: string) {
export function calcRarity(rarity?: string) {
if (!rarity) {
return 1
}
if (rarity.includes("5")) {
return 5
}
@@ -40,11 +43,12 @@ export function calcRarity(rarity: string) {
export function calcMainAffixBonus(affix?: MainAffixData, level?: number) {
if (!affix || typeof level !== "number") return "0"
const value = affix.BaseValue + affix.LevelAdd * level;
const statMeta = mappingStats[affix.Property]
if (mappingStats?.[affix.Property].unit === "%") {
if (statMeta?.unit === "%") {
return (value * 100).toFixed(1);
}
if (mappingStats?.[affix.Property].name === "SPD") {
if (statMeta?.name === "SPD") {
return value.toFixed(1);
}
@@ -53,10 +57,11 @@ export function calcMainAffixBonus(affix?: MainAffixData, level?: number) {
export const calcAffixBonus = (affix?: SubAffixData, stepCount?: number, rollCount?: number) => {
if (!affix || typeof stepCount !== "number" || typeof rollCount !== "number") return "0"
if (mappingStats?.[affix.Property].unit === "%") {
const statMeta = mappingStats[affix.Property]
if (statMeta?.unit === "%") {
return ((affix.BaseValue * rollCount + affix.StepValue * stepCount) * 100).toFixed(1);
}
if (mappingStats?.[affix.Property].name === "SPD") {
if (statMeta?.name === "SPD") {
return (affix.BaseValue * rollCount + affix.StepValue * stepCount).toFixed(1);
}
return (affix.BaseValue * rollCount + affix.StepValue * stepCount).toFixed(0);
@@ -107,6 +112,16 @@ export const calcMonsterStats = (
hardLevelConfig: Record<string, Record<string, HardLevelData>>,
eliteConfig: Record<string, EliteData>
) => {
if (!monster?.Base || !monster?.Modify) {
return {
atk: 0,
def: 0,
hp: 0,
spd: 0,
stance: 0,
}
}
let hardLevelRatio = {
AttackRatio: 1,
DefenceRatio:1,
@@ -135,4 +150,4 @@ export const calcMonsterStats = (
spd: monster.Base.SpeedBase * monster.Modify.SpeedModifyRatio * hardLevelRatio.SpeedRatio * eliteRatio.SpeedRatio,
stance: (monster.Base.StanceBase * monster.Modify.StanceModifyRatio * hardLevelRatio.StanceRatio * eliteRatio.StanceRatio) / 3,
}
}
}
+8 -7
View File
@@ -15,7 +15,7 @@ export function converterToAvatarStore(data: Record<string, AvatarDetail>): { [k
avatar_id: Number(key),
data: {
rank: 0,
skills: Object.values(value.SkillTrees).reduce((acc, dataPointEntry) => {
skills: Object.values(value.SkillTrees ?? {}).reduce((acc, dataPointEntry) => {
const firstEntry = Object.values(dataPointEntry)[0];
if (firstEntry) {
acc[firstEntry.PointID] = firstEntry.MaxLevel;
@@ -42,7 +42,8 @@ export function converterToAvatarStore(data: Record<string, AvatarDetail>): { [k
}
export function converterOneEnkaDataToAvatarStore(data: AvatarEnkaDetail, count: number): AvatarProfileStore | null {
if (!data.equipment && (!data.relicList || data.relicList.length === 0)) return null
const relicList = data.relicList ?? []
if (!data.equipment && relicList.length === 0) return null
const profile: AvatarProfileStore = {
profile_name: `Enka Profile ${count}`,
lightcone: (data.equipment && data.equipment.tid) ? {
@@ -51,12 +52,12 @@ export function converterOneEnkaDataToAvatarStore(data: AvatarEnkaDetail, count:
rank: data.equipment?.rank ?? 0,
promotion: data.equipment?.promotion ?? 0,
} : null,
relics: Object.fromEntries(data.relicList.map((relic) => [relic.tid.toString()[relic.tid.toString().length - 1], {
relics: Object.fromEntries(relicList.map((relic) => [relic.tid.toString()[relic.tid.toString().length - 1], {
level: relic.level ?? 0,
relic_id: relic.tid,
relic_set_id: parseInt(relic.tid.toString().slice(1, -1), 10),
main_affix_id: relic.mainAffixId,
sub_affixes: relic.subAffixList.map((subAffix) => ({
sub_affixes: (relic.subAffixList ?? []).map((subAffix) => ({
sub_affix_id: subAffix.affixId,
count: subAffix.cnt,
step: subAffix.step ?? 0
@@ -68,8 +69,8 @@ export function converterOneEnkaDataToAvatarStore(data: AvatarEnkaDetail, count:
export function converterOneFreeSRDataToAvatarStore(data: FreeSRJson, count: number , avatar_id: number): AvatarProfileStore | null {
const lightcone = data.lightcones.find((lightcone) => lightcone.equip_avatar === avatar_id)
const relics = data.relics.filter((relic) => relic.equip_avatar === avatar_id)
const lightcone = (data.lightcones ?? []).find((lightcone) => lightcone.equip_avatar === avatar_id)
const relics = (data.relics ?? []).filter((relic) => relic.equip_avatar === avatar_id)
if (!lightcone && (!relics || relics.length === 0)) return null
const relicsMap = {} as Record<string, RelicStore>
@@ -79,7 +80,7 @@ export function converterOneFreeSRDataToAvatarStore(data: FreeSRJson, count: num
relic_id: relic.relic_id,
relic_set_id: relic.relic_set_id,
main_affix_id: relic.main_affix_id,
sub_affixes: relic.sub_affixes.map((subAffix) => ({
sub_affixes: (relic.sub_affixes ?? []).map((subAffix) => ({
sub_affix_id: subAffix.sub_affix_id,
count: subAffix.count,
step: subAffix.step ?? 0
+11 -7
View File
@@ -84,7 +84,9 @@ export function converterToFreeSRJson(
let internalUidLightcone = 0
let internalUidRelic = 0
Object.entries(avatars).forEach(([avatarId, avatar]) => {
Object.entries(avatars ?? {}).forEach(([avatarId, avatar]) => {
if (!avatar) return
const skillsByAnchorType: Record<string, number> = {}
for (const [skillId, level] of Object.entries(avatar?.data?.skills || {})) {
if (skillConfig?.[skillId]) {
@@ -95,18 +97,20 @@ export function converterToFreeSRJson(
owner_uid: Number(avatar.owner_uid || 0),
avatar_id: Number(avatar.avatar_id || 0),
data: {
rank: Number(avatar.data.rank || 0),
skills: avatar.data.skills,
rank: Number(avatar.data?.rank || 0),
skills: avatar.data?.skills ?? {},
skills_by_anchor_type: Object.keys(skillsByAnchorType).length > 0 ? skillsByAnchorType : undefined,
},
level: Number(avatar.level || 0),
promotion: Number(avatar.promotion || 0),
techniques: avatar.techniques,
techniques: avatar.techniques ?? [],
sp_value: Number(avatar.sp_value || 0),
sp_max: Number(avatar.sp_max || 0),
enhanced_id: avatar.enhanced ? Number(avatar.enhanced || 0) : undefined,
}
const currentProfile = avatar.profileList[avatar.profileSelect]
const currentProfile = avatar.profileList?.[avatar.profileSelect] ?? avatar.profileList?.[0]
if (!currentProfile) return
if (currentProfile.lightcone && currentProfile.lightcone.item_id !== 0) {
const newLightcone: LightconeJson = {
level: Number(currentProfile.lightcone.level || 0),
@@ -129,7 +133,7 @@ export function converterToFreeSRJson(
relic_id: Number(relic.relic_id || 0),
relic_set_id: Number(relic.relic_set_id || 0),
main_affix_id: Number(relic.main_affix_id || 0),
sub_affixes: relic.sub_affixes,
sub_affixes: relic.sub_affixes ?? [],
internal_uid: internalUidRelic,
equip_avatar: Number(avatar.avatar_id || 0),
}
@@ -147,4 +151,4 @@ export function converterToFreeSRJson(
avatars: avatarsJson,
battle_config: battleJson,
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export function getSkillTree(avatarSelected: AvatarDetail | null, enhanced: stri
}, {} as Record<string, number>)
}
return Object.values(avatarSelected?.SkillTrees).reduce((acc, dataPointEntry) => {
return Object.values(avatarSelected?.SkillTrees ?? {}).reduce((acc, dataPointEntry) => {
const firstEntry = Object.values(dataPointEntry)[0];
if (firstEntry) {
acc[firstEntry.PointID] = firstEntry.MaxLevel;
+19 -11
View File
@@ -1,15 +1,23 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function downloadJson(fileName: string, data: any) {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
export function downloadJson(fileName: string, data: any): boolean {
if (typeof document === "undefined") return false
const link = document.createElement('a')
link.href = url
link.download = `${fileName}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
try {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
URL.revokeObjectURL(url)
const link = document.createElement('a')
link.href = url
link.download = `${fileName}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
return true
} catch (error) {
console.error("Failed to download JSON:", error)
return false
}
}
+8 -1
View File
@@ -1,4 +1,11 @@
export function randomPartition(sum: number, parts: number): number[] {
if (!Number.isFinite(sum) || !Number.isInteger(parts) || parts <= 0) {
return [];
}
if (sum < parts) {
return Array.from({ length: parts }, () => 0);
}
const raw = Array.from({ length: parts }, () => Math.random());
const total = raw.reduce((a, b) => a + b, 0);
const result = raw.map(r => Math.floor((r / total) * (sum - parts)) + 1);
@@ -24,4 +31,4 @@ export function randomStep(x: number): number {
total += Math.floor(Math.random() * 3);
}
return total;
}
}
+3 -2
View File
@@ -13,7 +13,8 @@ const formatValue = (value: number, format: string, floatDigits?: string, hasPer
return String(value);
};
export function replaceByParam(desc: string, params: number[]): string {
export function replaceByParam(desc?: string, params: number[] = []): string {
if (!desc) return "";
const PARAM_REGEX = /#(\d+)\[(f(\d+)|i)\](%)?/g;
@@ -35,4 +36,4 @@ export function replaceByParam(desc: string, params: number[]): string {
result = result.replace(PARAM_REGEX, processor);
return result.split("\\n").join("<br/>");
}
}
+15 -4
View File
@@ -1,5 +1,7 @@
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware';
import { persist } from 'zustand/middleware';
import { connectPersistedSchema } from '@/zod';
import { createValidatedJSONStorage } from './persistStorage';
interface ConnectState {
@@ -15,8 +17,10 @@ interface ConnectState {
setPassword: (newPassword: string) => void;
}
type ConnectPersistedState = Pick<ConnectState, "connectionType" | "privateType" | "serverUrl" | "username" | "password">;
const useConnectStore = create<ConnectState>()(
persist(
persist<ConnectState, [], [], ConnectPersistedState>(
(set) => ({
connectionType: "FireflyGo",
privateType: "Local",
@@ -31,9 +35,16 @@ const useConnectStore = create<ConnectState>()(
}),
{
name: 'connect-storage',
storage: createJSONStorage(() => localStorage),
storage: createValidatedJSONStorage(() => localStorage, connectPersistedSchema),
partialize: (state) => ({
connectionType: state.connectionType,
privateType: state.privateType,
serverUrl: state.serverUrl,
username: state.username,
password: state.password,
}),
}
)
);
export default useConnectStore;
export default useConnectStore;
+3 -3
View File
@@ -6,7 +6,7 @@ interface EnkaState {
enkaData: EnkaResponse | null;
uidInput: string;
setSelectedCharacters: (newListAvatar: CharacterInfoCardType[]) => void;
setEnkaData: (newEnkaData: EnkaResponse) => void;
setEnkaData: (newEnkaData: EnkaResponse | null) => void;
setUidInput: (newUidInput: string) => void;
}
@@ -16,7 +16,7 @@ const useEnkaStore = create<EnkaState>((set) => ({
uidInput: "",
setUidInput: (newUidInput: string) => set({ uidInput: newUidInput }),
setSelectedCharacters: (newListAvatar: CharacterInfoCardType[]) => set({ selectedCharacters: newListAvatar }),
setEnkaData: (newEnkaData: EnkaResponse) => set({ enkaData: newEnkaData }),
setEnkaData: (newEnkaData: EnkaResponse | null) => set({ enkaData: newEnkaData }),
}));
export default useEnkaStore;
export default useEnkaStore;
+14 -4
View File
@@ -1,6 +1,8 @@
import { ChangelogItemType } from '@/types';
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware';
import { persist } from 'zustand/middleware';
import { localePersistedSchema } from '@/zod';
import { createValidatedJSONStorage } from './persistStorage';
interface LocaleState {
@@ -14,8 +16,10 @@ interface LocaleState {
setLocale: (newLocale: string) => void;
}
type LocalePersistedState = Pick<LocaleState, "locale" | "theme" | "currentVersion" | "changelog">;
const useLocaleStore = create<LocaleState>()(
persist(
persist<LocaleState, [], [], LocalePersistedState>(
(set) => ({
locale: "en",
theme: "night",
@@ -28,9 +32,15 @@ const useLocaleStore = create<LocaleState>()(
}),
{
name: 'locale-storage',
storage: createJSONStorage(() => localStorage),
storage: createValidatedJSONStorage(() => localStorage, localePersistedSchema),
partialize: (state) => ({
locale: state.locale,
theme: state.theme,
currentVersion: state.currentVersion,
changelog: state.changelog,
}),
}
)
);
export default useLocaleStore;
export default useLocaleStore;
+79
View File
@@ -0,0 +1,79 @@
import type { PersistStorage, StateStorage, StorageValue } from "zustand/middleware";
import type { ZodType } from "zod";
export function createValidatedJSONStorage<S>(
getStorage: () => StateStorage,
stateSchema: ZodType<S>
): PersistStorage<S> | undefined {
let storage: StateStorage;
try {
storage = getStorage();
} catch {
return undefined;
}
const removeInvalidItem = (name: string, reason: string) => {
console.warn(`Invalid persisted store "${name}" was cleared: ${reason}`);
storage.removeItem(name);
};
return {
getItem: (name) => {
const raw = storage.getItem(name);
if (raw === null) {
return null;
}
if (raw instanceof Promise) {
return raw.then((value) => parseStorageValue(name, value, stateSchema, removeInvalidItem));
}
return parseStorageValue(name, raw, stateSchema, removeInvalidItem);
},
setItem: (name, value) => {
storage.setItem(name, JSON.stringify(value));
},
removeItem: (name) => {
storage.removeItem(name);
},
};
}
function parseStorageValue<S>(
name: string,
raw: string | null,
stateSchema: ZodType<S>,
removeInvalidItem: (name: string, reason: string) => void
): StorageValue<S> | null {
if (raw === null) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
removeInvalidItem(name, "malformed JSON");
return null;
}
if (!parsed || typeof parsed !== "object" || !("state" in parsed)) {
removeInvalidItem(name, "missing persisted state");
return null;
}
const storedValue = parsed as StorageValue<unknown>;
const state = stateSchema.safeParse(storedValue.state);
if (!state.success) {
removeInvalidItem(name, "state schema mismatch");
return null;
}
return {
state: state.data,
version: typeof storedValue.version === "number" ? storedValue.version : undefined,
};
}
+26 -4
View File
@@ -1,6 +1,8 @@
import { ASConfigStore, AvatarStore, CEConfigStore, MOCConfigStore, PEAKConfigStore, PFConfigStore } from '@/types';
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware';
import { persist } from 'zustand/middleware';
import { micsSchema } from '@/zod';
import { createValidatedJSONStorage } from './persistStorage';
interface UserDataState {
@@ -21,8 +23,19 @@ interface UserDataState {
setCeConfig: (newCeConfig: CEConfigStore) => void;
}
type UserDataPersistedState = Pick<
UserDataState,
| "avatars"
| "battle_type"
| "moc_config"
| "pf_config"
| "as_config"
| "peak_config"
| "ce_config"
>;
const useUserDataStore = create<UserDataState>()(
persist(
persist<UserDataState, [], [], UserDataPersistedState>(
(set) => ({
avatars: {},
battle_type: "",
@@ -84,9 +97,18 @@ const useUserDataStore = create<UserDataState>()(
}),
{
name: 'user-data-storage',
storage: createJSONStorage(() => localStorage),
storage: createValidatedJSONStorage(() => localStorage, micsSchema),
partialize: (state) => ({
avatars: state.avatars,
battle_type: state.battle_type,
moc_config: state.moc_config,
pf_config: state.pf_config,
as_config: state.as_config,
peak_config: state.peak_config,
ce_config: state.ce_config,
}),
}
)
);
export default useUserDataStore;
export default useUserDataStore;
+2 -2
View File
@@ -31,7 +31,7 @@ interface FlatProp {
interface RelicFlat {
props: FlatProp[]
setName: string
setName: string | number
setID: number
}
@@ -51,7 +51,7 @@ interface SkillTree {
interface EquipmentFlat {
props: FlatProp[]
name: string
name: string | number
}
interface Equipment {
+1 -4
View File
@@ -1,5 +1,6 @@
// Generated by ts-to-zod
import { z } from "zod";
import { lightconeStoreSchema, relicStoreSchema } from "./mics.zod";
export const characterInfoCardTypeSchema = z.object({
key: z.number(),
@@ -18,10 +19,6 @@ export const characterInfoCardTypeSchema = z.object({
}))
});
const lightconeStoreSchema = z.any();
const relicStoreSchema = z.any();
export const avatarProfileCardTypeSchema = z.object({
key: z.number(),
profile_name: z.string(),
+2 -2
View File
@@ -33,7 +33,7 @@ const flatPropSchema = z.object({
const relicFlatSchema = z.object({
props: z.array(flatPropSchema),
setName: z.string(),
setName: z.union([z.string(), z.number()]),
setID: z.number()
});
@@ -53,7 +53,7 @@ const skillTreeSchema = z.object({
const equipmentFlatSchema = z.object({
props: z.array(flatPropSchema),
name: z.string()
name: z.union([z.string(), z.number()])
});
const equipmentSchema = z.object({
+1
View File
@@ -5,5 +5,6 @@ export * from "./enka.zod"
export * from "./card.zod"
export * from "./extraData.zod"
export * from "./showcase.zod"
export * from "./persistedStore.zod"
export * from "./srtools.zod"
+18
View File
@@ -125,4 +125,22 @@ export const micsSchema = z.object({
as_config: asConfigStoreSchema,
ce_config: ceConfigStoreSchema,
peak_config: peakConfigStoreSchema
}).superRefine((data, ctx) => {
for (const [avatarId, avatar] of Object.entries(data.avatars)) {
if (avatar.profileList.length === 0) {
ctx.addIssue({
code: "custom",
path: ["avatars", avatarId, "profileList"],
message: "profileList must contain at least one profile"
});
}
if (avatar.profileSelect < 0 || avatar.profileSelect >= avatar.profileList.length) {
ctx.addIssue({
code: "custom",
path: ["avatars", avatarId, "profileSelect"],
message: "profileSelect must point to an existing profile"
});
}
}
});
+24
View File
@@ -0,0 +1,24 @@
import { z } from "zod";
import type { ChangelogItemType } from "@/types";
export const changelogItemSchema: z.ZodType<ChangelogItemType> = z.object({
version: z.string(),
date: z.string(),
type: z.string(),
items: z.array(z.string()),
});
export const localePersistedSchema = z.object({
locale: z.string(),
theme: z.string(),
currentVersion: z.string(),
changelog: z.array(changelogItemSchema),
});
export const connectPersistedSchema = z.object({
connectionType: z.string(),
privateType: z.string(),
serverUrl: z.string(),
username: z.string(),
password: z.string(),
});