UPDATE: Update readme and Next 16.07 (CVE-2025-66478)

This commit is contained in:
2025-12-04 23:27:41 +07:00
commit 6b079db470
280 changed files with 364214 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadAS } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const asData = await loadAS([id], locale)
const as = asData[id]
if (!as) {
return NextResponse.json({ error: 'AS info not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(as), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { loadAS } from "@/lib/loader";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const asIds = body.asIds as string[];
const { locale } = await params;
if (!Array.isArray(asIds) || asIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid asIds' }, { status: 400 });
}
const asData = await loadAS(asIds, locale);
return new NextResponse(JSON.stringify(asData), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load as data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadCharacters } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const characters = await loadCharacters([id], locale)
const char = characters[id]
if (!char) {
return NextResponse.json({ error: 'Character not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(char), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { loadCharacters } from "@/lib/loader";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const charIds = body.charIds as string[];
const { locale } = await params;
if (!Array.isArray(charIds) || charIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid charIds' }, { status: 400 });
}
const characters = await loadCharacters(charIds, locale);
return new NextResponse(JSON.stringify(characters), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load characters' }, { status: 500 });
}
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadLightcones } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const lightcones = await loadLightcones([id], locale)
const lightcone = lightcones[id]
if (!lightcone) {
return NextResponse.json({ error: 'Lightcone not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(lightcone), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { loadLightcones } from "@/lib/loader";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const lightconeIds = body.lightconeIds as string[];
const { locale } = await params;
if (!Array.isArray(lightconeIds) || lightconeIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid lightconeIds' }, { status: 400 });
}
const lightcones = await loadLightcones(lightconeIds, locale);
return new NextResponse(JSON.stringify(lightcones), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load lightcones' }, { status: 500 });
}
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadMOC } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const mocData = await loadMOC([id], locale)
const moc = mocData[id]
if (!moc) {
return NextResponse.json({ error: 'MOC info not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(moc), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { loadMOC } from "@/lib/loader";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const mocIds = body.mocIds as string[];
const { locale } = await params;
if (!Array.isArray(mocIds) || mocIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid mocIds' }, { status: 400 });
}
const mocData = await loadMOC(mocIds, locale);
return new NextResponse(JSON.stringify(mocData), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load moc data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadMonster } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const monsterData = await loadMonster([id], locale)
const monster = monsterData[id]
if (!monster) {
return NextResponse.json({ error: 'Monster info not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(monster), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { loadMonster } from "@/lib/loader";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const monsterIds = body.monsterIds as string[];
const { locale } = await params;
if (!Array.isArray(monsterIds) || monsterIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid monsterIds' }, { status: 400 });
}
const monsterData = await loadMonster(monsterIds, locale);
return new NextResponse(JSON.stringify(monsterData), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load monster data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadPeak } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const peakData = await loadPeak([id], locale)
const peak = peakData[id]
if (!peak) {
return NextResponse.json({ error: 'Peak info not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(peak), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { loadPeak } from "@/lib/loader";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const peakIds = body.peakIds as string[];
const { locale } = await params;
if (!Array.isArray(peakIds) || peakIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid peakIds' }, { status: 400 });
}
const peakData = await loadPeak(peakIds, locale);
return new NextResponse(JSON.stringify(peakData), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load peak data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadPF } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const pfData = await loadPF([id], locale)
const pf = pfData[id]
if (!pf) {
return NextResponse.json({ error: 'PF info not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(pf), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { loadPF } from "@/lib/loader";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const pfIds = body.pfIds as string[];
const { locale } = await params;
if (!Array.isArray(pfIds) || pfIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid pfIds' }, { status: 400 });
}
const pfData = await loadPF(pfIds, locale);
return new NextResponse(JSON.stringify(pfData), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load pf data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { loadRelics } from '@/lib/loader'
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string, locale: string }> }
) {
const { id, locale } = await params
const relics = await loadRelics([id], locale)
const relic = relics[id]
if (!relic) {
return NextResponse.json({ error: 'Relic not found' }, { status: 404 })
}
return new NextResponse(JSON.stringify(relic), {
headers: { "Content-Type": "application/json" }
});
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { loadRelics } from "@/lib/loader";
export async function POST(request: NextRequest, { params }: { params: Promise<{ locale: string }> }) {
try {
const body = await request.json();
const relicIds = body.relicIds as string[];
const { locale } = await params;
if (!Array.isArray(relicIds) || relicIds.some(id => typeof id !== 'string')) {
return NextResponse.json({ error: 'Invalid relicIds' }, { status: 400 });
}
const relics = await loadRelics(relicIds, locale);
return new NextResponse(JSON.stringify(relics), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load relics' }, { status: 500 });
}
}

View File

@@ -0,0 +1,13 @@
import { loadConfigMaze } from "@/lib/loader";
import { NextResponse } from "next/server";
export async function GET() {
try {
const configMaze = await loadConfigMaze();
return new NextResponse(JSON.stringify(configMaze), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load config maze' }, { status: 500 });
}
}

View File

@@ -0,0 +1,13 @@
import { loadMainAffix } from "@/lib/loader";
import { NextResponse } from "next/server";
export async function GET() {
try {
const mainAffix = await loadMainAffix();
return new NextResponse(JSON.stringify(mainAffix), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load main affix' }, { status: 500 });
}
}

102
src/app/api/proxy/route.ts Normal file
View File

@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import axios from 'axios'
import net from 'net'
function isPrivateHost(hostname: string): boolean {
if (
hostname === 'localhost' ||
hostname.startsWith('127.') ||
hostname.startsWith('10.') ||
hostname.startsWith('192.168.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
) {
return true
}
if (net.isIP(hostname)) {
return true
}
return false
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const targetUrl = searchParams.get("url")
if (!targetUrl) {
return NextResponse.json({ error: "Missing url" }, { status: 400 })
}
const response = await fetch(targetUrl)
if (!response.ok) {
return NextResponse.json({ error: "Failed to fetch image" }, { status: 500 })
}
const buffer = await response.arrayBuffer()
return new NextResponse(buffer, {
headers: {
"Content-Type": response.headers.get("content-type") || "image/png",
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*",
},
})
} catch (e: unknown) {
return NextResponse.json({ error: (e as Error)?.message }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { serverUrl, method, ...payload } = body
if (!serverUrl) {
return NextResponse.json({ error: 'Missing serverUrl' }, { status: 400 })
}
if (!method) {
return NextResponse.json({ error: 'Missing method' }, { status: 400 })
}
let url = serverUrl.trim()
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = `http://${url}`
}
const parsed = new URL(url)
if (isPrivateHost(parsed.hostname)) {
return NextResponse.json(
{ error: `Connection to private/internal address (${parsed.hostname}) is not allowed` },
{ status: 403 }
)
}
let response
switch (method.toUpperCase()) {
case 'GET':
const queryString = new URLSearchParams(payload as Record<string, string>).toString()
const fullUrl = queryString ? `${url}?${queryString}` : url
response = await axios.get(fullUrl)
break
case 'POST':
response = await axios.post(url, payload)
break
case 'PUT':
response = await axios.put(url, payload)
break
case 'DELETE':
response = await axios.delete(url, { data: payload })
break
default:
return NextResponse.json({ error: `Unsupported method: ${method}` }, { status: 405 })
}
return NextResponse.json(response.data)
} catch (err: unknown) {
return NextResponse.json({ error: (err as Error)?.message || 'Proxy failed' }, { status: 500 })
}
}

View File

@@ -0,0 +1,13 @@
import { loadSubAffix } from "@/lib/loader";
import { NextResponse } from "next/server";
export async function GET() {
try {
const subAffix = await loadSubAffix();
return new NextResponse(JSON.stringify(subAffix), {
headers: { "Content-Type": "application/json" }
});
} catch {
return NextResponse.json({ error: 'Failed to load sub affix' }, { status: 500 });
}
}

View File

@@ -0,0 +1,9 @@
"use client"
import EidolonsInfo from "@/components/eidolonsInfo";
export default function EidolonsInfoPage() {
return (
<div className="w-full">
<EidolonsInfo/>
</div>
);
}

69
src/app/globals.css Normal file
View File

@@ -0,0 +1,69 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: winter --default, night --prefersdark, cupcake, coffee;
}
@plugin 'tailwind-scrollbar' {
nocompatible: true;
}
:root {
--size-big: 4vw;
--size-medium: 3vw;
--size-small: 2vw;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #9ca3af;
border-radius: 0;
}
::-webkit-scrollbar-button {
display: none;
}
@tailwind utilities;
@layer components {
/* .my-react-select-container {
} */
.my-react-select-container .my-react-select__control {
@apply bg-white dark:bg-neutral-700 border-2 border-neutral-300 dark:border-neutral-700 hover:border-neutral-400 dark:hover:border-neutral-500;
}
.my-react-select-container .my-react-select__control--is-focused {
@apply border-neutral-500 hover:border-neutral-500 dark:border-neutral-400 dark:hover:border-neutral-400 shadow-none;
}
.my-react-select-container .my-react-select__menu {
@apply bg-neutral-100 dark:bg-neutral-700 border-2 border-neutral-300 dark:border-neutral-600;
}
.my-react-select-container .my-react-select__option {
@apply text-neutral-600 dark:text-neutral-200 bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-800;
}
/* .my-react-select-container .my-react-select__option--is-focused {
@apply bg-neutral-200 dark:bg-neutral-800;
} */
.my-react-select-container .my-react-select__indicator-separator {
@apply bg-neutral-400;
}
.my-react-select-container .my-react-select__menu {
z-index: 999;
}
.my-react-select-container .my-react-select__input-container,
.my-react-select-container .my-react-select__placeholder,
.my-react-select-container .my-react-select__single-value {
@apply text-neutral-600 dark:text-neutral-200;
}
}

102
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "../components/header";
import { ClientThemeWrapper, ThemeProvider } from "@/components/themeController";
import Footer from "@/components/footer";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { ToastContainer } from 'react-toastify';
import QueryProviderWrapper from "@/components/queryProvider";
import ClientDataFetcher from "@/components/clientDataFetcher";
import AvatarBar from "@/components/avatarBar";
import ActionBar from "@/components/actionBar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Firefly SrTools",
description: "SrTools by Kain",
icons: {
icon: "/ff-srtool.png",
shortcut: "/ff-srtool.ico",
apple: "/ff-srtool.png",
},
openGraph: {
title: "Firefly SrTools",
description: "SrTools by Kain",
url: "https://srtools.kain.id.vn",
siteName: "Firefly SrTools",
images: [
{
url: "https://srtools.kain.id.vn/ff-srtool.png",
width: 312,
height: 312,
alt: "Firefly SrTools Logo",
},
],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Firefly SrTools",
description: "SrTools by Kain",
images: ["https://srtools.kain.id.vn/ff-srtool.png"],
},
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const messages = await getMessages();
const locale = await getLocale()
return (
<html lang={locale} suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
suppressHydrationWarning
>
<NextIntlClientProvider messages={messages}>
<QueryProviderWrapper>
<ThemeProvider>
<ClientThemeWrapper>
<ClientDataFetcher />
<div className="min-h-screen w-full">
<Header />
<div className="grid grid-cols-12 w-full">
<div className="hidden sm:block col-span-3 sticky top-0 self-start h-fit">
<AvatarBar />
</div>
<div className="col-span-12 sm:col-span-9 lg:col-span-9">
<ActionBar />
{children}
</div>
</div>
<Footer />
</div>
</ClientThemeWrapper>
</ThemeProvider>
</QueryProviderWrapper>
</NextIntlClientProvider>
<ToastContainer />
</body>
</html>
);
}

10
src/app/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
"use client"
import AvatarInfo from "@/components/avatarInfo";
export default function Home() {
return (
<div className="w-full">
<AvatarInfo></AvatarInfo>
</div>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import RelicsInfo from "@/components/relicsInfo";
export default function RelicsInfoPage() {
return (
<div className="w-full">
<RelicsInfo></RelicsInfo>
</div>
);
}

View File

@@ -0,0 +1,11 @@
"use client"
import ShowCaseInfo from "@/components/showcaseCard"
export default function ShowcaseCard() {
return (
<div>
<ShowCaseInfo />
</div>
)
}

View File

@@ -0,0 +1,11 @@
"use client";
import SkillsInfo from "@/components/skillsInfo";
export default function SkillsInfoPage() {
return (
<div className="w-full">
<SkillsInfo></SkillsInfo>
</div>
);
}

View File

@@ -0,0 +1,457 @@
"use client";
import useListAvatarStore from "@/stores/avatarStore";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useRouter } from 'next/navigation'
import ParseText from "../parseText";
import useLocaleStore from "@/stores/localeStore";
import { getNameChar } from "@/helper/getName";
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import useModelStore from "@/stores/modelStore";
import useUserDataStore from "@/stores/userDataStore";
import { RelicStore } from "@/types";
import { toast } from "react-toastify";
import useGlobalStore from "@/stores/globalStore";
import { connectToPS, syncDataToPS } from "@/helper";
import CopyImport from "../importBar/copy";
import useCopyProfileStore from "@/stores/copyProfile";
import AvatarBar from "../avatarBar";
export default function ActionBar() {
const router = useRouter()
const { avatarSelected, listRawAvatar } = useListAvatarStore()
const { setListCopyAvatar } = useCopyProfileStore()
const transI18n = useTranslations("DataPage")
const { locale } = useLocaleStore()
const {
isOpenCreateProfile,
setIsOpenCreateProfile,
isOpenCopy,
setIsOpenCopy,
isOpenAvatars,
setIsOpenAvatars
} = useModelStore()
const { avatars, setAvatar } = useUserDataStore()
const [profileName, setProfileName] = useState("");
const [formState, setFormState] = useState("EDIT");
const [profileEdit, setProfileEdit] = useState(-1);
const { isConnectPS } = useGlobalStore()
const profileCurrent = useMemo(() => {
if (!avatarSelected) return null;
const avatar = avatars[avatarSelected.id];
return avatar?.profileList[avatar.profileSelect] || null;
}, [avatarSelected, avatars]);
const listProfile = useMemo(() => {
if (!avatarSelected) return [];
const avatar = avatars[avatarSelected.id];
return avatar?.profileList || [];
}, [avatarSelected, avatars]);
const handleUpdateProfile = () => {
if (!profileName.trim()) return;
if (formState === "CREATE" && avatarSelected && avatars[avatarSelected.id]) {
const newListProfile = [...listProfile]
const newProfile = {
profile_name: profileName,
lightcone: null,
relics: {} as Record<string, RelicStore>
}
newListProfile.push(newProfile)
setAvatar({ ...avatars[avatarSelected.id], profileList: newListProfile, profileSelect: newListProfile.length - 1 })
toast.success("Profile created successfully")
} else if (formState === "EDIT" && profileCurrent && avatarSelected && profileEdit !== -1) {
const newListProfile = [...listProfile]
newListProfile[profileEdit].profile_name = profileName;
setAvatar({ ...avatars[avatarSelected.id], profileList: newListProfile })
toast.success("Profile updated successfully")
}
handleCloseModal("update_profile_modal");
};
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.showModal();
}
};
// Close modal handler
const handleCloseModal = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.close();
}
};
// Handle ESC key to close modal
useEffect(() => {
if (!isOpenCreateProfile) {
handleCloseModal("update_profile_modal");
return;
}
if (!isOpenCopy) {
handleCloseModal("copy_profile_modal");
return;
}
console.log(isOpenAvatars)
if (!isOpenAvatars) {
handleCloseModal("avatars_modal");
return;
}
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpenCreateProfile) {
handleCloseModal("update_profile_modal");
}
if (event.key === 'Escape' && isOpenCopy) {
handleCloseModal("copy_profile_modal");
}
if (event.key === 'Escape' && isOpenAvatars) {
handleCloseModal("avatars_modal");
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
}, [isOpenCopy, isOpenCreateProfile, isOpenAvatars]);
const actionMove = (path: string) => {
router.push(`/${path}`)
}
const handleProfileSelect = (profileId: number) => {
if (!avatarSelected) return;
if (avatars[avatarSelected.id].profileSelect === profileId) return;
setAvatar({ ...avatars[avatarSelected.id], profileSelect: profileId })
toast.success(`Profile changed to Profile: ${avatars[avatarSelected.id].profileList[profileId].profile_name}`)
}
const handleDeleteProfile = (profileId: number, e: React.MouseEvent) => {
e.stopPropagation()
if (!avatarSelected || profileId == 0) return;
if (window.confirm(`Are you sure you want to delete profile: ${avatars[avatarSelected.id].profileList[profileId].profile_name}?`)) {
const newListProfile = [...listProfile]
newListProfile.splice(profileId, 1)
setAvatar({ ...avatars[avatarSelected.id], profileList: newListProfile, profileSelect: profileId - 1 })
toast.success(`Profile ${avatars[avatarSelected.id].profileList[profileId].profile_name} deleted successfully`)
}
}
const handleConnectOrSyncPS = async () => {
if (isConnectPS) {
const res = await syncDataToPS()
if (res.success) {
toast.success(transI18n("syncSuccess"))
} else {
toast.error(`${transI18n("syncFailed")}: ${res.message}`)
}
} else {
const res = await connectToPS()
if (res.success) {
toast.success(transI18n("connectedSuccess"))
} else {
toast.error(`${transI18n("connectedFailed")}: ${res.message}`)
}
}
}
const modalConfigs = [
{
id: "update_profile_modal",
title: formState === "CREATE" ? transI18n("createNewProfile") : transI18n("editProfile"),
onClose: () => {
setIsOpenCreateProfile(false)
handleCloseModal("update_profile_modal")
},
content: (
<div className="px-6 space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text text-primary font-semibold text-lg">
{transI18n("profileName")}
</span>
</label>
<input
type="text"
placeholder={transI18n("placeholderProfileName")}
className="input input-warning mt-1 w-full"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
/>
</div>
<div className="modal-action">
<button className="btn btn-success btn-sm sm:btn-md" onClick={handleUpdateProfile}>
{formState === "CREATE" ? transI18n("create") : transI18n("update")}
</button>
</div>
</div>
)
},
{
id: "copy_profile_modal",
title: transI18n("copyProfiles").toUpperCase(),
onClose: () => {
setIsOpenCopy(false)
handleCloseModal("copy_profile_modal")
},
content: <CopyImport />
},
{
id: "avatars_modal",
title: transI18n("avatars").toUpperCase(),
onClose: () => {
setIsOpenAvatars(false)
handleCloseModal("avatars_modal")
},
content: <AvatarBar onClose={() => { setIsOpenAvatars(false); handleCloseModal("avatars_modal") }} />
}
]
return (
<div className="w-full px-4 pb-4 bg-base-200">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-center justify-items-center">
<div className="flex flex-col justify-center w-full">
<div className="flex flex-wrap items-center gap-2 ">
<div className="flex flex-wrap items-center h-full opacity-80 lg:hover:opacity-100 cursor-pointer text-base md:text-lg lg:text-xl">
{avatarSelected && (
<div className="flex items-center justify-start h-full w-full">
<Image
src={`/icon/${avatarSelected.damageType.toLowerCase()}.webp`}
alt={'fire'}
className="h-[40px] w-[40px] object-contain"
width={100}
height={100}
/>
<p className="text-center font-bold text-xl">
{transI18n(avatarSelected.baseType.toLowerCase())}
</p>
<div className="text-center font-bold text-xl">{" / "}</div>
<ParseText
locale={locale}
text={getNameChar(locale, avatarSelected).toWellFormed()}
className={"font-bold text-xl"}
/>
{avatarSelected?.id && (
<div className="text-center italic text-sm ml-2"> {`(${avatarSelected.id})`}</div>
)}
</div>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-base opacity-70 font-bold w-16">{transI18n("profile")}:</span>
<div className="dropdown dropdown-center md:dropdown-start">
<div
tabIndex={0}
role="button"
className="btn btn-warning border-info btn-soft gap-1"
>
<span className="truncate max-w-48 font-bold">
{profileCurrent?.profile_name}
</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
<ul className="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box min-w-max w-full mt-1 border border-base-300 max-h-60 overflow-y-auto">
{listProfile.map((profile, index) => (
<li key={index} className="grid grid-cols-12">
<button
className={`col-span-8 btn btn-ghost`}
onClick={() => handleProfileSelect(index)}
>
<span className="flex-1 truncate text-left">
{profile.profile_name}
{index === 0 && (
<span className="text-xs text-base-content/60 ml-1">({transI18n("default")})</span>
)}
</span>
</button>
{index !== 0 && (
<>
<button
className="col-span-2 flex items-center justify-center text-lg text-warning hover:bg-warning/20 z-20 w-full h-full"
onClick={() => {
setFormState("EDIT");
setProfileName(profile.profile_name)
setProfileEdit(index)
setIsOpenCreateProfile(true)
handleShow("update_profile_modal")
}}
title="Edit Profile"
>
<svg className="w-4 h-4 " fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M4 20h4l10.293-10.293a1 1 0 000-1.414l-2.586-2.586a1 1 0 00-1.414 0L4 16v4z" />
</svg>
</button>
<button
className="col-span-2 flex items-center justify-center text-error hover:bg-error/20 z-20 w-full h-full text-lg"
onClick={(e) => {
handleDeleteProfile(index, e)
}}
title="Delete Profile"
>
<svg className="w-4 h-4 " fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M9 7V4h6v3m5 0H4" />
</svg>
</button>
</>
)}
</li>
))}
<li className="border-t border-base-300 mt-2 pt-2 z-10">
<button
onClick={() => {
setIsOpenCopy(true)
setListCopyAvatar(listRawAvatar)
handleShow("copy_profile_modal")
}}
className="btn btn-ghost flex justify-start px-3 py-2 h-full w-full hover:bg-base-200 cursor-pointer text-primary z-20"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{transI18n("copyProfiles")}
</button>
<button
className="btn btn-ghost flex justify-start px-3 py-2 h-full w-full hover:bg-base-200 cursor-pointer text-primary z-20"
onClick={() => {
setIsOpenCreateProfile(true)
setFormState("CREATE");
setProfileName("")
handleShow("update_profile_modal")
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{transI18n("addNewProfile")}
</button>
</li>
</ul>
</div>
<div className=" grid grid-cols-2 w-full sm:hidden gap-2">
<button
onClick={() => {
setIsOpenAvatars(true)
handleShow("avatars_modal")
}}
className="col-span-1 btn btn-warning btn-sm w-full">
{transI18n("avatars")}
</button>
<div className="col-span-1 dropdown dropdown-center w-full">
<label tabIndex={0} className="btn btn-info btn-sm w-full">
{transI18n("actions")}
</label>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box min-w-max w-full mt-1 border border-base-300 max-h-60 overflow-y-auto"
>
<li>
<button onClick={() => actionMove('')}>
{transI18n("characterInformation")}
</button>
</li>
<li>
<button onClick={() => actionMove('relics-info')}>
{transI18n("relics")}
</button>
</li>
<li>
<button onClick={() => actionMove('eidolons-info')}>
{transI18n("eidolons")}
</button>
</li>
<li>
<button onClick={() => actionMove('skills-info')}>
{transI18n("skills")}
</button>
</li>
<li>
<button onClick={() => actionMove('showcase-card')}>
{transI18n("showcaseCard")}
</button>
</li>
<li>
<button onClick={handleConnectOrSyncPS} className="btn btn-primary btn-sm">
{isConnectPS ? transI18n("sync") : transI18n("connectPs")}
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="hidden sm:grid grid-cols-3 gap-2 w-full">
<button className="btn btn-success btn-sm" onClick={() => actionMove("")}>
{transI18n("characterInformation")}
</button>
<button className="btn btn-success btn-sm" onClick={() => actionMove("relics-info")}>
{transI18n("relics")}
</button>
<button className="btn btn-success btn-sm" onClick={() => actionMove("eidolons-info")}>
{transI18n("eidolons")}
</button>
<button className="btn btn-success btn-sm" onClick={() => actionMove("skills-info")}>
{transI18n("skills")}
</button>
<button className="btn btn-success btn-sm" onClick={() => actionMove("showcase-card")}>
{transI18n("showcaseCard")}
</button>
<button onClick={handleConnectOrSyncPS} className="btn btn-primary btn-sm">
{isConnectPS ? transI18n("sync") : transI18n("connectPs")}
</button>
</div>
{modalConfigs.map(({ id, title, onClose, content }) => (
<dialog key={id} id={id} className="modal">
<div className="modal-box w-11/12 max-w-7xl max-h-[85vh] bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="sticky top-0 z-10">
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
onClick={onClose}
>
</motion.button>
</div>
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-400">
{title}
</h3>
</div>
{content}
</div>
</dialog>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client"
import Image from "next/image"
import { useEffect } from "react"
import CharacterCard from "../card/characterCard"
import useLocaleStore from "@/stores/localeStore"
import useAvatarStore from "@/stores/avatarStore"
import { useTranslations } from "next-intl"
export default function AvatarBar({ onClose }: { onClose?: () => void }) {
const {
listAvatar,
setAvatarSelected,
setSkillSelected,
setFilter,
filter,
listElement,
listPath,
setListElement,
setListPath
} = useAvatarStore()
const transI18n = useTranslations("DataPage")
const { locale } = useLocaleStore()
useEffect(() => {
setFilter({ ...filter, locale: locale, element: Object.keys(listElement).filter((key) => listElement[key]), path: Object.keys(listPath).filter((key) => listPath[key]) })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [locale, listElement, listPath])
return (
<div className="grid grid-flow-row h-full auto-rows-max w-full">
<div className="h-full rounded-lg mx-2 py-2">
<div className="container">
<div className="flex flex-col h-full gap-2">
<div className="flex flex-col gap-2">
<div className="flex justify-center">
<input type="text"
placeholder={transI18n("placeholderCharacter")}
className="input input-bordered input-primary w-full"
value={filter.name}
onChange={(e) => setFilter({ ...filter, name: e.target.value, locale: locale })}
/>
</div>
<div className="grid grid-cols-7 sm:grid-cols-4 lg:grid-cols-7 mb-1 mx-1 gap-2 w-full max-h-[17vh] min-h-[5vh] overflow-y-auto">
{Object.keys(listElement).map((key, index) => (
<div
key={index}
onClick={() => {
setListElement({ ...listElement, [key]: !listElement[key] })
}}
className="hover:bg-gray-600 grid items-center justify-items-center cursor-pointer rounded-md shadow-lg"
style={{
backgroundColor: listElement[key] ? "#374151" : "#6B7280"
}}>
<Image src={ `/icon/${key}.webp`}
alt={key}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md"
width={200}
height={200} />
</div>
))}
</div>
<div className="grid grid-cols-8 sm:grid-cols-4 lg:grid-cols-8 mb-1 mx-1 gap-2 overflow-y-auto w-full max-h-[17vh] min-h-[5vh]">
{Object.keys(listPath).map((key, index) => (
<div
key={index}
onClick={() => {
setListPath({ ...listPath, [key]: !listPath[key] })
}}
className="hover:bg-gray-600 grid items-center justify-items-center rounded-md shadow-lg cursor-pointer"
style={{
backgroundColor: listPath[key] ? "#374151" : "#6B7280"
}}
>
<Image src={`/icon/${key}.webp`}
alt={key}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md"
width={200}
height={200} />
</div>
))}
</div>
</div>
<div className="flex items-start h-full">
<ul className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-2 w-full h-[65vh] overflow-y-scroll overflow-x-hidden">
{listAvatar.map((item, index) => (
<div key={index} onClick={() => {
setAvatarSelected(item);
setSkillSelected(null)
if (onClose) onClose()
}}>
<CharacterCard data={item} />
</div>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,532 @@
"use client"
import useAvatarStore from "@/stores/avatarStore"
import useUserDataStore from "@/stores/userDataStore";
import { useEffect, useMemo } from "react";
import { motion } from "framer-motion";
import LightconeBar from '../lightconeBar'
import useLightconeStore from '@/stores/lightconeStore'
import { calcPromotion, calcRarity, replaceByParam } from '@/helper';
import { getSkillTree } from '@/helper/getSkillTree';
import { useTranslations } from 'next-intl';
import ParseText from '../parseText';
import useLocaleStore from '@/stores/localeStore';
import useModelStore from '@/stores/modelStore';
import useMazeStore from '@/stores/mazeStore';
import Image from 'next/image';
export default function AvatarInfo() {
const { avatarSelected, mapAvatarInfo } = useAvatarStore()
const { Technique } = useMazeStore()
const { avatars, setAvatars, setAvatar } = useUserDataStore()
const { isOpenLightcone, setIsOpenLightcone } = useModelStore()
const { listLightcone, mapLightconeInfo, setDefaultFilter } = useLightconeStore()
const transI18n = useTranslations("DataPage")
const { locale } = useLocaleStore();
const lightcone = useMemo(() => {
if (!avatarSelected) return null;
const avatar = avatars[avatarSelected.id];
return avatar?.profileList[avatar.profileSelect]?.lightcone || null;
}, [avatarSelected, avatars]);
const lightconeDetail = useMemo(() => {
if (!lightcone) return null;
return listLightcone.find((item) => Number(item.id) === Number(lightcone.item_id)) || null;
}, [lightcone, listLightcone]);
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
setIsOpenLightcone(true);
modal.showModal();
}
};
// Close modal handler
const handleCloseModal = (modalId: string) => {
setIsOpenLightcone(false);
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.close();
}
};
// Handle ESC key to close modal
useEffect(() => {
if (!isOpenLightcone) {
handleCloseModal("action_detail_modal");
return;
}
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpenLightcone) {
handleCloseModal("action_detail_modal");
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpenLightcone ]);
return (
<div className="bg-base-100 max-h-[77vh] min-h-[50vh] overflow-y-scroll overflow-x-hidden">
{avatarSelected && avatars[avatarSelected?.id || ""] && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 w-full">
<div className="m-2 min-h-96">
<div className="container">
<div className="card bg-base-200 shadow-xl">
<div className="card-body">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="card-title text-2xl font-bold flex items-center gap-2">
<div className="w-2 h-8 bg-linear-to-b from-success to-success/50 rounded-full"></div>
{transI18n("characterSettings")}
</h2>
</div>
<div className="space-y-6">
{/* Level Control */}
<div className="bg-base-100 rounded-xl p-6 border border-base-content/10">
<h4 className="text-lg font-semibold mb-4 flex items-center gap-2">
<div className="w-2 h-6 bg-linear-to-b from-info to-info/50 rounded-full"></div>
{transI18n("levelConfiguration")}
</h4>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">{transI18n("characterLevel")}</span>
<span className="label-text-alt text-info font-mono">{avatars[avatarSelected?.id || ""]?.level}/80</span>
</label>
<div className="relative">
<input
type="number"
min="1"
max="80"
value={avatars[avatarSelected?.id || ""]?.level}
onChange={(e) => {
const newLevel = Math.min(80, Math.max(1, parseInt(e.target.value) || 1));
setAvatars({ ...avatars, [avatarSelected?.id || ""]: { ...avatars[avatarSelected?.id || ""], level: newLevel, promotion: calcPromotion(newLevel) } });
}}
className="input input-bordered w-full pr-16 font-mono"
placeholder={transI18n("placeholderLevel")}
/>
<div
onClick={() => {
setAvatars({ ...avatars, [avatarSelected?.id || ""]: { ...avatars[avatarSelected?.id || ""], level: 80, promotion: calcPromotion(80) } });
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/60 cursor-pointer">
<span className="text-sm">{transI18n("max")}</span>
</div>
</div>
<div className="mt-2">
<input
type="range"
min="1"
max="80"
value={avatars[avatarSelected?.id || ""]?.level}
onChange={(e) => {
const newLevel = Math.min(80, Math.max(1, parseInt(e.target.value) || 1));
setAvatars({ ...avatars, [avatarSelected?.id || ""]: { ...avatars[avatarSelected?.id || ""], level: newLevel, promotion: calcPromotion(newLevel) } });
}}
className="range range-info range-sm w-full"
/>
</div>
</div>
</div>
{/* Energy Control */}
<div className="bg-base-100 rounded-xl p-6 border border-base-content/10">
<h4 className="text-lg font-semibold mb-4 flex items-center gap-2">
<div className="w-2 h-6 bg-linear-to-b from-warning to-warning/50 rounded-full"></div>
{transI18n("ultimateEnergy")}
</h4>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">{transI18n("currentEnergy")}</span>
<span className="label-text text-warning font-mono">
{Math.round(avatars[avatarSelected?.id || ""]?.sp_value)}/{avatars[avatarSelected?.id || ""]?.sp_max}
</span>
</label>
<input
type="range"
min="0"
max={avatars[avatarSelected?.id || ""]?.sp_max}
value={avatars[avatarSelected?.id || ""]?.sp_value}
onChange={(e) => {
if (!avatars[avatarSelected?.id || ""]?.can_change_sp) return
const newSpValue = Math.min(avatars[avatarSelected?.id || ""]?.sp_max, Math.max(0, parseInt(e.target.value) || 0));
setAvatars({ ...avatars, [avatarSelected?.id || ""]: { ...avatars[avatarSelected?.id || ""], sp_value: newSpValue } });
}}
className="range range-warning range-sm w-full"
/>
<div className="flex justify-between text-sm text-base-content/60 mt-1">
<span>0%</span>
<span className="font-mono text-warning">{((avatars[avatarSelected?.id || ""]?.sp_value / avatars[avatarSelected?.id || ""]?.sp_max) * 100).toFixed(1)}%</span>
<span>100%</span>
</div>
<div className="mt-3">
<button
className="btn btn-sm btn-outline btn-warning"
onClick={() => {
if (!avatars[avatarSelected?.id || ""]?.can_change_sp) return
const newSpValue = Math.ceil(avatars[avatarSelected?.id || ""]?.sp_max / 2);
const newAvatar = { ...avatars[avatarSelected?.id || ""], sp_value: newSpValue }
setAvatar(newAvatar)
}}
>
{transI18n("setTo50")}
</button>
</div>
</div>
</div>
{/* Technique Toggle */}
<div className="bg-base-100 rounded-xl p-6 border border-base-content/10">
<h4 className="text-lg font-semibold mb-4 flex items-center gap-2">
<div className="w-2 h-6 bg-linear-to-b from-accent to-accent/50 rounded-full"></div>
{transI18n("battleConfiguration")}
</h4>
<div className="form-control">
<label className="grid grid-cols-1 gap-2 sm:grid-cols-2 label cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="label-text font-medium">{transI18n("useTechnique")}</span>
</div>
<input
type="checkbox"
checked={avatars[avatarSelected?.id || ""]?.techniques.length > 0}
onChange={(e) => {
if (!Technique[avatarSelected?.id || ""] || Technique[avatarSelected?.id || ""]?.maze_buff.length === 0) return
const techniques = e.target.checked ? Technique[avatarSelected?.id || ""]?.maze_buff : [];
const newAvatar = { ...avatars[avatarSelected?.id || ""], techniques };
setAvatar(newAvatar);
}}
className="toggle toggle-accent"
/>
</label>
<div className="text-xs text-base-content/60 mt-1">
{transI18n("techniqueNote")}
</div>
</div>
</div>
{/* Enhancement Selection */}
{Object.entries(mapAvatarInfo[avatarSelected?.id || ""]?.Enhanced || {}).length > 0 && (
<div className="bg-base-100 rounded-xl p-6 border border-base-content/10">
<h4 className="text-lg font-semibold mb-4 flex items-center gap-2">
<svg className="w-5 h-5 text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
</svg>
{transI18n("enhancement")}
</h4>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">{transI18n("enhancementLevel")}</span>
<span className="label-text-alt text-secondary font-mono">
{avatars[avatarSelected?.id || ""]?.enhanced || transI18n("origin")}
</span>
</label>
<select
value={avatars[avatarSelected?.id || ""]?.enhanced || ""}
onChange={(e) => {
const newAvatar = avatars[avatarSelected?.id || ""]
if (newAvatar) {
newAvatar.enhanced = e.target.value
const skillTree = getSkillTree(e.target.value)
if (skillTree) {
newAvatar.data.skills = skillTree
}
setAvatar(newAvatar)
}
}}
className="select select-bordered select-secondary"
>
<option value="">{transI18n("origin")}</option>
{Object.keys(mapAvatarInfo[avatarSelected?.id || ""]?.Enhanced || {}).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
<div className="text-xs text-base-content/60 mt-1">
{transI18n("enhancedNote")}
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
<div className="m-2 min-h-96">
<div className="container">
<div className="card bg-base-200 shadow-xl">
<div className="card-body">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="card-title text-2xl font-bold flex items-center gap-2">
<div className="w-2 h-8 bg-linear-to-b from-primary to-secondary rounded-full"></div>
{transI18n("lightconeEquipment")}
</h2>
</div>
{lightcone && lightconeDetail ? (
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-3">
{/* Level & Rank Controls */}
<div className="bg-base-100 rounded-xl p-6 border border-base-content/10">
<h4 className="text-lg font-semibold mb-4 flex items-center gap-2">
<div className="w-2 h-6 bg-linear-to-b from-accent to-accent/50 rounded-full"></div>
{transI18n("lightconeSettings")}
</h4>
<div className="grid md:grid-cols-2 gap-6">
{/* Level Input */}
<div className="form-control">
<label className="label">
<span className="label-text font-medium">{transI18n("level")}</span>
<span className="label-text-alt text-primary font-mono">{lightcone.level}/80</span>
</label>
<div className="relative">
<input
type="number"
min="1"
max="80"
value={lightcone.level || 1}
onChange={(e) => {
const newLightconeLevel = Math.min(80, Math.max(1, parseInt(e.target.value) || 1))
const newLightcone = { ...lightcone, level: newLightconeLevel, promotion: calcPromotion(newLightconeLevel) }
const newAvatar = { ...avatars[avatarSelected.id] }
newAvatar.profileList[newAvatar.profileSelect].lightcone = newLightcone
setAvatar(newAvatar)
}}
className="input input-bordered w-full pr-16 font-mono"
placeholder={transI18n("placeholderLevel")}
/>
<div
onClick={() => {
const newLightcone = { ...lightcone, level: 80, promotion: calcPromotion(80) }
const newAvatar = { ...avatars[avatarSelected.id] }
newAvatar.profileList[newAvatar.profileSelect].lightcone = newLightcone
setAvatar(newAvatar)
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/60 cursor-pointer">
<span className="text-sm">{transI18n("max")}</span>
</div>
</div>
<div className="mt-2">
<input
type="range"
min="1"
max="80"
value={lightcone.level || 1}
onChange={(e) => {
const newLightconeLevel = Math.min(80, Math.max(1, parseInt(e.target.value) || 1))
const newLightcone = { ...lightcone, level: newLightconeLevel, promotion: calcPromotion(newLightconeLevel) }
const newAvatar = { ...avatars[avatarSelected.id] }
newAvatar.profileList[newAvatar.profileSelect].lightcone = newLightcone
setAvatar(newAvatar)
}}
className="range range-primary range-sm"
/>
</div>
</div>
{/* Rank Selection */}
<div className="form-control space-y-3">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
<span className="font-medium text-sm sm:text-base">{transI18n("superimpositionRank")}</span>
<span className="text-info font-mono text-lg sm:text-xl font-semibold">S{lightcone.rank}</span>
</div>
{/* Rank Buttons */}
<div className="w-full">
<div className="grid grid-cols-5 gap-1.5 sm:gap-2 md:grid-cols-3 xl:grid-cols-5 max-w-sm mx-auto sm:mx-0">
{[1, 2, 3, 4, 5].map((r) => (
<button
key={r}
onClick={() => {
const newLightcone = { ...lightcone, rank: r }
const newAvatar = { ...avatars[avatarSelected.id] }
newAvatar.profileList[newAvatar.profileSelect].lightcone = newLightcone
setAvatar(newAvatar)
}}
className={`
btn btn-sm sm:btn-md font-mono font-semibold
transition-all duration-200 hover:scale-105
${lightcone.rank === r
? 'btn-primary shadow-lg'
: 'btn-outline btn-primary hover:btn-primary'
}`}
>
S{r}
</button>
))}
</div>
</div>
{/* Help Text */}
<div className="text-xs sm:text-sm text-base-content/60 text-center sm:text-left mt-2">
{transI18n("ranksNote")}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-3 mt-2">
<button
onClick={() => {
if (avatarSelected) {
const newDefaultFilter = { path: [avatarSelected.baseType.toLowerCase()], rarity: [] }
setDefaultFilter(newDefaultFilter)
handleShow("action_detail_modal")
}
}}
className="btn btn-primary btn-lg flex-1 min-w-fit"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
{transI18n("changeLightcone")}
</button>
<button
onClick={() => {
const newAvatar = { ...avatars[avatarSelected.id] }
newAvatar.profileList[newAvatar.profileSelect].lightcone = null
setAvatar(newAvatar)
}}
className="btn btn-warning btn-lg flex-1 min-w-fit"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{transI18n("removeLightcone")}
</button>
</div>
</div>
{/* Lightcone Image */}
<div className="lg:col-span-1">
<div className="">
<Image
width={904}
height={1260}
priority
src={`https://api.hakush.in/hsr/UI/lightconemaxfigures/${lightconeDetail.id}.webp`}
className="w-full h-full rounded-lg object-cover shadow-lg"
alt="Lightcone"
/>
</div>
</div>
{/* Lightcone Info & Controls */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
{mapLightconeInfo[lightcone.item_id] && (
<div className="bg-base-300 rounded-xl p-6 border border-base-content/10">
<div className="flex flex-wrap items-center gap-3 mb-4">
<h3 className="text-2xl font-bold text-base-content">
<ParseText
locale={locale}
text={mapLightconeInfo[lightcone.item_id].Name}
/>
</h3>
<div className="badge badge-outline badge-lg">
{transI18n(mapLightconeInfo[lightcone.item_id].BaseType.toLowerCase())}
</div>
<div className="badge badge-outline badge-lg">
{calcRarity(mapLightconeInfo[lightcone.item_id].Rarity) + "⭐"}
</div>
<div className="badge badge-outline badge-lg">
{"id: " + lightcone.item_id}
</div>
</div>
<div className="prose prose-sm max-w-none">
<div
className="text-base-content/80 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
mapLightconeInfo[lightcone.item_id].Refinements.Desc,
mapLightconeInfo[lightcone.item_id].Refinements.Level[lightcone.rank.toString()]?.ParamList || []
)
}}
/>
</div>
</div>
)}
</div>
</div>
) : (
/* No Lightcone Equipped State */
<div className="text-center py-12">
<div
onClick={() => handleShow("action_detail_modal")}
className="w-24 h-24 mx-auto mb-6 bg-base-300 rounded-full flex items-center justify-center cursor-pointer"
>
<svg className="w-12 h-12 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<h3 className="text-xl font-semibold mb-2">{transI18n("noLightconeEquipped")}</h3>
<p className="text-base-content/60 mb-6">{transI18n("equipLightconeNote")}</p>
<button
onClick={() => {
if (avatarSelected) {
const newDefaultFilter = { path: [avatarSelected.baseType.toLowerCase()], rarity: [] }
setDefaultFilter(newDefaultFilter)
handleShow("action_detail_modal")
}
}}
className="btn btn-success btn-lg"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
{transI18n("equipLightcone")}
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
<dialog id="action_detail_modal" className="modal backdrop-blur-sm">
<div className="modal-box w-11/12 max-w-5xl max-h-[85vh] bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="sticky top-0 z-10">
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
onClick={() => handleCloseModal("action_detail_modal")}
>
</motion.button>
</div>
<LightconeBar />
</div>
</dialog>
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import { getNameChar } from '@/helper';
import useLocaleStore from '@/stores/localeStore';
import { CharacterBasic } from '@/types';
import ParseText from '../parseText';
import Image from 'next/image';
interface CharacterCardProps {
data: CharacterBasic
}
export default function CharacterCard({ data }: CharacterCardProps) {
const { locale } = useLocaleStore();
const text = getNameChar(locale, data)
return (
<li
className="z-10 flex flex-col items-center rounded-xl shadow-xl
bg-linear-to-br from-base-300 via-base-100 to-warning/70
transform transition-transform duration-300 ease-in-out
hover:scale-105 cursor-pointer min-h-[170px] sm:min-h-[180px] md:min-h-[210px] lg:min-h-[220px] xl:min-h-[240px] 2xl:min-h-[340px]"
>
<div
className={`w-full rounded-md bg-linear-to-br ${data.rank === "CombatPowerAvatarRarityType5"
? "from-yellow-400 via-yellow-600/70 to-yellow-800/50"
: "from-purple-400 via-purple-600/70 to-purple-800/50"
}`}
>
<div className="relative w-full h-full">
<Image
width={376}
height={512}
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${data.id}.webp`}
priority={true}
className="w-full h-full rounded-md object-cover"
alt="ALT"
/>
<Image
width={32}
height={32}
src={`/icon/${data.damageType.toLowerCase()}.webp`}
className="absolute top-0 left-0 w-6 h-6 rounded-full"
alt={data.damageType.toLowerCase()}
/>
<Image
width={32}
height={32}
src={`/icon/${data.baseType.toLowerCase()}.webp`}
className="absolute top-0 right-0 w-6 h-6 rounded-full"
alt={data.baseType.toLowerCase()}
style={{
boxShadow: "inset 0 0 8px 4px #9CA3AF"
}}
/>
</div>
</div>
<ParseText
locale={locale}
text={text}
className="mt-2 px-1 text-center text-shadow-white font-bold leading-tight text-sm sm:text-base 2xl:text-lg"
/>
</li>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import React from 'react';
import { CharacterInfoCardType } from '@/types';
import useLocaleStore from '@/stores/localeStore';
import useAvatarStore from '@/stores/avatarStore';
import useLightconeStore from '@/stores/lightconeStore';
import Image from 'next/image';
import ParseText from '../parseText';
export default function CharacterInfoCard({ character, selectedCharacters, onCharacterToggle }: { character: CharacterInfoCardType, selectedCharacters: CharacterInfoCardType[], onCharacterToggle: (characterId: CharacterInfoCardType) => void }) {
const isSelected = selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatar_id);
const { mapAvatarInfo } = useAvatarStore();
const { mapLightconeInfo } = useLightconeStore();
const { locale } = useLocaleStore();
return (
<div
className={`bg-base-200/60 rounded-xl p-4 border cursor-pointer transition-all duration-200 ${isSelected
? 'border-blue-400 ring-2 ring-blue-400/50'
: 'border-base-300/50 hover:border-base-300 opacity-75'
}`}
onClick={() => onCharacterToggle(character)}
>
{/* Character Portrait */}
<div className="relative mb-4">
<div className="w-full h-48 rounded-lg overflow-hidden relative">
<Image
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${character.avatar_id}.webp`}
alt={mapAvatarInfo[character.avatar_id.toString()]?.Name || ""}
width={376}
height={512}
className="w-full h-full object-contain"
/>
<Image
width={48}
height={48}
src={`/icon/${mapAvatarInfo[character.avatar_id.toString()]?.DamageType.toLowerCase()}.webp`}
className="absolute top-0 left-0 w-10 h-10 rounded-full"
alt={mapAvatarInfo[character.avatar_id.toString()]?.DamageType.toLowerCase()}
/>
<Image
width={48}
height={48}
src={`/icon/${mapAvatarInfo[character.avatar_id.toString()]?.BaseType.toLowerCase()}.webp`}
className="absolute top-0 right-0 w-10 h-10 rounded-full"
alt={mapAvatarInfo[character.avatar_id.toString()]?.BaseType.toLowerCase()}
style={{
boxShadow: "inset 0 0 8px 4px #9CA3AF"
}}
/>
</div>
</div>
{/* Character Name and Level */}
<div className="w-full rounded-lg flex items-center justify-center mb-2">
<div className="text-center">
<ParseText className="text-lg font-bold"
text={mapAvatarInfo[character.avatar_id.toString()]?.Name}
locale={locale}
/>
<div className="text-base mb-1">Lv.{character.level} E{character.rank}</div>
</div>
</div>
{character.relics.length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-2 mb-4">
{character.relics.map((relic, index) => (
<div key={index} className="relative">
<div className="w-9 h-9 rounded-lg flex items-center justify-center border border-amber-500/50">
<Image
src={`https://api.hakush.in/hsr/UI/relicfigures/IconRelic_${relic.relic_set_id}_${relic.relic_id.toString()[relic.relic_id.toString().length - 1]}.webp`}
alt="Relic"
width={124}
height={124}
className="w-14 h-14 object-contain"
/>
</div>
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 bg-slate-800 text-white text-xs px-1 rounded">
+{relic.level}
</div>
</div>
))}
</div>
)}
{/* Light Cone */}
{character.lightcone.item_id && (
<div className="">
<div className="rounded-lg h-42 flex items-center justify-center">
<Image
src={`https://api.hakush.in/hsr/UI/lightconemediumicon/${character.lightcone.item_id}.webp`}
alt={mapLightconeInfo[character.lightcone.item_id.toString()]?.Name}
width={348}
height={408}
className="w-full h-full object-contain rounded-lg"
/>
</div>
<div className="w-full h-full rounded-lg flex items-center justify-center">
<div className="text-center">
<div className="text-lg font-bold">
<ParseText
text={mapLightconeInfo[character.lightcone.item_id.toString()]?.Name}
locale={locale}
/>
</div>
<div className="text-base mb-1">Lv.{character.lightcone.level} S{character.lightcone.rank}</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,51 @@
"use client";
import { getLocaleName } from '@/helper';
import useLocaleStore from '@/stores/localeStore';
import { LightConeBasic } from '@/types';
import ParseText from '../parseText';
import Image from 'next/image';
interface LightconeCardProps {
data: LightConeBasic
}
export default function LightconeCard({ data }: LightconeCardProps) {
const { locale } = useLocaleStore();
const text = getLocaleName(locale, data)
return (
<li className="z-10 flex flex-col items-center rounded-md shadow-lg
bg-linear-to-b from-customStart to-customEnd transform transition-transform duration-300
hover:scale-105 cursor-pointer min-h-[220px]"
>
<div
className={`w-full rounded-md bg-linear-to-br ${data.rank === "CombatPowerLightconeRarity5"
? "from-yellow-400 via-yellow-600/70 to-yellow-800/50"
: data.rank === "CombatPowerLightconeRarity4" ? "from-purple-400 via-purple-600/70 to-purple-800/50" :
"from-blue-400 via-blue-600/70 to-blue-800/50"
}`}
>
<div className="relative w-full h-full">
<Image
loading="lazy"
src={`https://api.hakush.in/hsr/UI/lightconemediumicon/${data.id}.webp`}
width={348}
height={408}
className="w-full h-full rounded-md object-cover"
alt="ALT"
/>
</div>
</div>
<ParseText
locale={locale}
text={text}
className="mt-2 px-1 text-center text-sm sm:text-base font-bold leading-tight"
/>
</li>
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import React from 'react';
import { AvatarProfileCardType } from '@/types';
import useLocaleStore from '@/stores/localeStore';
import useLightconeStore from '@/stores/lightconeStore';
import Image from 'next/image';
import ParseText from '../parseText';
export default function ProfileCard({ profile, selectedProfile, onProfileToggle }: { profile: AvatarProfileCardType, selectedProfile: AvatarProfileCardType[], onProfileToggle: (profileId: AvatarProfileCardType) => void }) {
const isSelected = selectedProfile.some((selectedProfile) => selectedProfile.key === profile.key);
const { mapLightconeInfo } = useLightconeStore();
const { locale } = useLocaleStore();
return (
<div
className={`bg-base-200/60 rounded-xl p-4 border cursor-pointer transition-all duration-200 ${isSelected
? 'border-blue-400 ring-2 ring-blue-400/50'
: 'border-base-300/50 hover:border-base-300 opacity-75'
}`}
onClick={() => onProfileToggle(profile)}
>
{/* Light Cone */}
{profile.lightcone && (
<div className="">
<div className="rounded-lg h-42 flex items-center justify-center">
<Image
src={`https://api.hakush.in/hsr/UI/lightconemediumicon/${profile.lightcone.item_id}.webp`}
alt={mapLightconeInfo[profile.lightcone.item_id.toString()]?.Name}
width={348}
height={408}
className="w-full h-full object-contain rounded-lg"
/>
</div>
<div className="w-full h-full rounded-lg flex items-center justify-center">
<div className="text-center">
<div className="text-lg font-bold">
<ParseText
text={mapLightconeInfo[profile.lightcone.item_id.toString()]?.Name}
locale={locale}
/>
</div>
<div className="text-base mb-1">Lv.{profile.lightcone.level} S{profile.lightcone.rank}</div>
</div>
</div>
</div>
)}
{Object.keys(profile.relics).length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-2 mb-4">
{Object.values(profile.relics).map((relic, index) => (
<div key={index} className="relative">
<div className="w-9 h-9 rounded-lg flex items-center justify-center border border-amber-500/50">
<Image
src={`https://api.hakush.in/hsr/UI/relicfigures/IconRelic_${relic.relic_set_id}_${relic.relic_id.toString()[relic.relic_id.toString().length - 1]}.webp`}
alt="Relic"
width={124}
height={124}
className="w-14 h-14 object-contain"
/>
</div>
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 bg-slate-800 text-white text-xs px-1 rounded">
+{relic.level}
</div>
</div>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,182 @@
"use client";
import useRelicMakerStore from "@/stores/relicMakerStore";
import useUserDataStore from "@/stores/userDataStore";
import Image from "next/image";
import { useMemo } from "react";
interface RelicCardProps {
slot: string
avatarId: string
}
const getRarityColor = (rarity: string) => {
switch (rarity) {
case '3': return 'border-green-500 shadow-green-500/50 bg-linear-to-br from-green-700 via-green-400 to-green-500';
case '4': return 'border-blue-500 shadow-blue-500/50 bg-linear-to-br from-blue-700 via-blue-400 to-blue-500';
case '5': return 'border-purple-500 shadow-purple-500/50 bg-linear-to-br from-purple-700 via-purple-400 to-purple-500';
case '6': return 'border-yellow-500 shadow-yellow-500/50 bg-linear-to-br from-yellow-700 via-yellow-400 to-yellow-500';
default: return 'border-gray-500 shadow-gray-500/50';
}
};
const getRarityName = (slot: string) => {
switch (slot) {
case '1': return (
<div className="flex items-center gap-1">
<Image
src="/relics/HEAD.png"
alt="Head"
width={20}
height={20}
className="bg-black/50 rounded-full"
/>
<h2>Head</h2>
</div>
);
case '2': return (
<div className="flex items-center gap-1">
<Image
src="/relics/HAND.png"
alt="Hand"
width={20}
height={20}
className="bg-black/50 rounded-full"
/>
<h2>Hands</h2>
</div>
);
case '3': return (
<div className="flex items-center gap-1">
<Image
src="/relics/BODY.png"
alt="Body"
width={20}
height={20}
className="bg-black/50 rounded-full"
/>
<h2>Body</h2>
</div>
);
case '4': return (
<div className="flex items-center gap-1">
<Image
src="/relics/FOOT.png"
alt="Foot"
width={20}
height={20}
className="bg-black/50 rounded-full"
/>
<h2>Feet</h2>
</div>
);
case '5': return (
<div className="flex items-center gap-1">
<Image
src="/relics/NECK.png"
alt="Neck"
width={20}
height={20}
className="bg-black/50 rounded-full"
/>
<h2>Planar sphere</h2>
</div>
);
case '6': return (
<div className="flex items-center gap-1">
<Image
src="/relics/OBJECT.png"
alt="Object"
width={20}
height={20}
className="bg-black/50 rounded-full"
/>
<h2>Link rope</h2>
</div>
);
default: return '';
}
};
export default function RelicCard({ slot, avatarId }: RelicCardProps) {
const { avatars } = useUserDataStore()
const { selectedRelicSlot } = useRelicMakerStore()
const relicDetail = useMemo(() => {
const avatar = avatars[avatarId];
if (avatar) {
if (avatar.profileList[avatar.profileSelect].relics[slot]) {
return avatar.profileList[avatar.profileSelect].relics[slot];
}
return null;
}
return null;
}, [avatars, avatarId, slot]);
return (
<div>
{relicDetail ? (
<div
className="flex flex-col items-center cursor-pointer ">
<div
className={`
relative w-24 h-24 rounded-full
${getRarityColor(relicDetail.relic_id.toString()[0])}
shadow-xl
flex items-center justify-center
cursor-pointer transition-transform
${selectedRelicSlot === slot ? 'ring-5 ring-success scale-105' : 'ring-3 ring-primary'}
`}
>
<span>
<Image
src={`https://api.hakush.in/hsr/UI/relicfigures/IconRelic_${relicDetail.relic_set_id}_${slot}.webp`}
alt="Relic"
width={124}
height={124}
className="w-14 h-14 object-contain"
/>
</span>
{/* Level Badge */}
<div className="absolute -bottom-2 bg-base-100 border-2 border-base-300 rounded-full px-2 py-1">
<span className="text-sm font-bold text-primary">+{relicDetail.level}</span>
</div>
</div>
<div className="mt-3 text-center">
<div className="text-sm font-medium text-base-content">{getRarityName(slot)}</div>
</div>
</div>
) : (
<div
className="flex flex-col items-center cursor-pointer">
<div
className={`
relative w-24 h-24 rounded-full border-4
${getRarityColor("None")}
bg-base-300 shadow-xl
flex items-center justify-center
cursor-pointer hover:scale-105 transition-transform
ring-4 ring-primary
`}
>
<span className="text-3xl">
<svg className="w-12 h-12 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</span>
{/* Level Badge */}
<div className="absolute -bottom-2 bg-base-100 border-2 border-base-300 rounded-full px-2 py-1">
<span className="text-sm font-bold text-primary">+{0}</span>
</div>
</div>
<div className="mt-3 text-center">
<div>{getRarityName(slot)}</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client";
import {
useFetchASData,
useFetchAvatarData,
useFetchConfigData,
useFetchLightconeData,
useFetchMOCData,
useFetchMonsterData,
useFetchPEAKData,
useFetchPFData,
useFetchRelicData
} from "@/hooks";
export default function ClientDataFetcher() {
useFetchConfigData();
useFetchAvatarData();
useFetchLightconeData();
useFetchRelicData();
useFetchMonsterData();
useFetchPFData();
useFetchMOCData();
useFetchASData();
useFetchPEAKData();
return null;
}

View File

@@ -0,0 +1,167 @@
"use client"
import { connectToPS, syncDataToPS } from "@/helper"
import useConnectStore from "@/stores/connectStore"
import useGlobalStore from "@/stores/globalStore"
import { useTranslations } from "next-intl"
import { useState } from "react"
export default function ConnectBar() {
const transI18n = useTranslations("DataPage")
const [message, setMessage] = useState({ text: '', type: '' });
const {
connectionType,
privateType,
serverUrl,
username,
password,
setConnectionType,
setPrivateType,
setServerUrl,
setUsername,
setPassword
} = useConnectStore()
const { isConnectPS } = useGlobalStore()
return (
<div className="px-6 py-4">
{/* Select connection type */}
<div className="form-control grid grid-cols-1 w-full mb-6">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("connectionType")}</span>
</label>
<select
className="select w-full select-bordered border-purple-500/30 focus:border-purple-500 bg-base-200 mt-1"
value={connectionType}
onChange={(e) => setConnectionType(e.target.value)}
>
<option value="FireflyGo">FireflyGo</option>
<option value="Other">{transI18n("other")}</option>
</select>
</div>
{/* Show host/port if Other */}
{connectionType === "Other" && (
<div className="flex flex-col md:space-x-4 mb-6 gap-2">
<div className="form-control w-full mb-4 md:mb-0">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("serverUrl")}</span>
</label>
<input
type="text"
placeholder={transI18n("placeholderServerUrl")}
className="input input-bordered w-full border-purple-500/30 focus:border-purple-500 bg-base-200 mt-1"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
/>
</div>
<div className="form-control w-full mb-4 md:mb-0">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("privateType")}</span>
</label>
<select
className="select w-full select-bordered border-purple-500/30 focus:border-purple-500 bg-base-200 mt-1"
value={privateType}
onChange={(e) => setPrivateType(e.target.value)}
>
<option value="Local">{transI18n("local")}</option>
<option value="Server">{transI18n("server")}</option>
</select>
</div>
<div className="form-control w-full mb-4 md:mb-0">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("username")}</span>
</label>
<input
type="text"
placeholder={transI18n("placeholderUsername")}
className="input input-bordered w-full border-purple-500/30 focus:border-purple-500 bg-base-200 mt-1"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="form-control w-full mb-4 md:mb-0">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("password")}</span>
</label>
<input
type="password"
placeholder={transI18n("placeholderPassword")}
className="input input-bordered w-full border-purple-500/30 focus:border-purple-500 bg-base-200 mt-1"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
)}
{message.text && (
<div className={`alert ${message.type === 'success' ? 'alert-success' :
message.type === 'error' ? 'alert-error' : 'alert-info'
} mb-6`}>
<span>{message.text}</span>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6 mb-2">
{/* Status */}
<div className="flex items-center justify-center md:justify-start">
<span className="text-md mr-2">{transI18n("status")}:</span>
<span
className={`badge ${isConnectPS ? "badge-success" : "badge-error"
} badge-lg`}
>
{isConnectPS ? transI18n("connected") : transI18n("unconnected")}
</span>
</div>
{/* Buttons */}
<div className="flex flex-col sm:flex-row gap-2 w-full justify-center md:justify-end">
<button
onClick={async () => {
const response = await connectToPS();
if (response.success) {
setMessage({
type: "success",
text: transI18n("connectedSuccess"),
});
} else {
setMessage({
type: "error",
text: response.message,
});
}
}}
className="btn btn-primary w-full sm:w-auto"
>
{transI18n("connectPs")}
</button>
{isConnectPS && (
<button
onClick={async () => {
const response = await syncDataToPS();
if (response.success) {
setMessage({
type: "success",
text: transI18n("syncSuccess"),
});
} else {
setMessage({
type: "error",
text: `${transI18n("syncFailed")}: ${response.message}`,
});
}
}}
className="btn btn-success w-full sm:w-auto"
>
{transI18n("sync")}
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client"
import { replaceByParam } from "@/helper";
import useListAvatarStore from "@/stores/avatarStore";
import Image from "next/image";
import ParseText from "../parseText";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
export default function EidolonsInfo() {
const { avatarSelected, mapAvatarInfo } = useListAvatarStore()
const { locale } = useLocaleStore()
const transI18n = useTranslations("DataPage")
const { setAvatars, avatars } = useUserDataStore()
const charRank = useMemo(() => {
if (!avatarSelected) return null;
const avatar = avatars[avatarSelected.id];
if (avatar?.enhanced != "") {
return mapAvatarInfo[avatarSelected.id]?.Enhanced[avatar?.enhanced].Ranks
}
return mapAvatarInfo[avatarSelected.id]?.Ranks
}, [avatarSelected, avatars, locale, mapAvatarInfo]);
return (
<div className="bg-base-100 rounded-xl p-6 shadow-lg">
<h2 className="flex items-center gap-2 text-2xl font-bold mb-6 text-base-content">
<div className="w-2 h-6 bg-linear-to-b from-primary to-primary/50 rounded-full"></div>
{transI18n("eidolons")}
</h2>
<div className="grid grid-cols-1 m-4 p-4 font-bold gap-4 w-fit max-h-[77vh] min-h-[50vh] overflow-y-scroll overflow-x-hidden">
{charRank && avatars[avatarSelected?.id || ""] && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(charRank || {}).map(([key, rank]) => (
<div key={key}
className="flex flex-col items-center cursor-pointer hover:scale-105"
onClick={() => {
let newRank = Number(key)
if (avatars[avatarSelected?.id || ""]?.data?.rank == Number(key)) {
newRank = Number(key) - 1
}
setAvatars({ ...avatars, [avatarSelected?.id || ""]: { ...avatars[avatarSelected?.id || ""], data: { ...avatars[avatarSelected?.id || ""].data, rank: newRank } } })
}}
>
<Image
className={`w-60 object-contain mb-2 ${Number(key) <= avatars[avatarSelected?.id || ""]?.data?.rank ? "" : "grayscale"}`}
src={`https://api.hakush.in/hsr/UI/rank/_dependencies/textures/${avatarSelected?.id}/${avatarSelected?.id}_Rank_${key}.webp`}
alt={`Rank ${key}`}
priority
width={240}
height={240}
/>
<div className="text-lg pb-1 flex items-center justify-items-center gap-2">
<span className="inline-block text-indigo-500">{key}.</span>
<ParseText
locale={locale}
text={rank.Name}
className="text-center text-base font-normal leading-tight"
/>
</div>
<div className="text-sm font-normal">
<div dangerouslySetInnerHTML={{ __html: replaceByParam(rank.Desc, rank.ParamList) }} />
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
'use client'
import { motion } from "framer-motion"
import { EyeOff, Eye, Hammer, RefreshCw, ShieldBan } from "lucide-react"
import useGlobalStore from '@/stores/globalStore';
import { useTranslations } from "next-intl";
export default function ExtraSettingBar() {
const { extraData, setExtraData } = useGlobalStore()
const transI18n = useTranslations("DataPage")
return (
<div className="px-4 sm:px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Theorycraft Mode */}
{extraData?.theory_craft?.mode !== undefined && (
<motion.div
whileHover={{ scale: 1.02 }}
className="form-control bg-base-200 p-4 rounded-xl shadow"
>
<label className="flex flex-wrap items-center label cursor-pointer justify-start gap-3">
<Hammer className="text-primary" size={20}/>
<input
type="checkbox"
className="toggle toggle-primary"
checked={extraData?.theory_craft?.mode}
onChange={(e) =>
setExtraData({
...extraData,
theory_craft: { ...extraData?.theory_craft, mode: e.target.checked }
})
}
/>
<span className="label-text font-semibold">{transI18n("theoryCraftMode")}</span>
</label>
</motion.div>
)}
{/* Cycle Count */}
{extraData?.theory_craft?.mode && (
<motion.div
whileHover={{ scale: 1.02 }}
className="form-control bg-base-200 p-4 rounded-xl shadow"
>
<label className="flex flex-wrap items-center label justify-start gap-3">
<RefreshCw className="text-info" size={20}/>
<span className="label-text font-semibold">{transI18n("cycleCount")}</span>
<input
type="number"
className="input input-bordered"
value={extraData?.theory_craft?.cycle_count}
onChange={(e) =>
setExtraData({
...extraData,
theory_craft: {
...extraData?.theory_craft,
cycle_count: parseInt(e.target.value) || 1
}
})
}
min="1"
/>
</label>
</motion.div>
)}
{/* Hidden UI */}
{extraData?.setting?.hide_ui !== undefined && (
<motion.div
whileHover={{ scale: 1.02 }}
className="form-control bg-base-200 p-4 rounded-xl shadow"
>
<label className="flex flex-wrap items-center label cursor-pointer justify-start gap-3">
{extraData?.setting?.hide_ui
? <EyeOff className="text-warning" size={20}/>
: <Eye className="text-success" size={20}/>
}
<input
type="checkbox"
className="toggle toggle-secondary"
checked={extraData?.setting?.hide_ui}
onChange={(e) =>
setExtraData({
...extraData,
setting: { ...extraData?.setting, hide_ui: e.target.checked }
})
}
/>
<span className="label-text font-semibold">{transI18n("hideUI")}</span>
</label>
</motion.div>
)}
{/* Censorship */}
{extraData?.setting?.censorship !== undefined && (
<motion.div
whileHover={{ scale: 1.02 }}
className="form-control bg-base-200 p-4 rounded-xl shadow"
>
<label className="flex flex-wrap items-center label cursor-pointer justify-start gap-3">
<ShieldBan className="text-error" size={20}/>
<input
type="checkbox"
className="toggle toggle-accent"
checked={extraData?.setting?.censorship}
onChange={(e) =>
setExtraData({
...extraData,
setting: { ...extraData?.setting, censorship: e.target.checked }
})
}
/>
<span className="label-text font-semibold">{transI18n("disableCensorship")}</span>
</label>
</motion.div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export default function Footer() {
return(
<footer className="footer footer-horizontal footer-center bg-base-200 text-base-content rounded p-10">
<aside>
<p>Copyright © {new Date().getFullYear()} - Kain (Powered by Nextjs & DaisyUi)</p>
</aside>
</footer>
)
}

View File

@@ -0,0 +1,597 @@
"use client"
import { downloadJson } from "@/helper";
import { converterToFreeSRJson } from "@/helper/converterToFreeSRJson";
import { useChangeTheme } from "@/hooks/useChangeTheme";
import { listCurrentLanguage } from "@/constant/constant";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import EnkaImport from "../importBar/enka";
import useModelStore from "@/stores/modelStore";
import FreeSRImport from "../importBar/freesr";
import { toast } from "react-toastify";
import { micsSchema } from "@/zod";
import useGlobalStore from "@/stores/globalStore";
import MonsterBar from "../monsterBar";
import Image from "next/image";
import ConnectBar from "../connectBar";
import ExtraSettingBar from "../extraSettingBar";
const themes = [
{ label: "Winter" },
{ label: "Night" },
{ label: "Cupcake" },
{ label: "Coffee" },
];
export default function Header() {
const { changeTheme } = useChangeTheme()
const { locale, setLocale } = useLocaleStore()
const {
avatars,
battle_type,
setAvatars,
setBattleType,
moc_config,
pf_config,
as_config,
ce_config,
peak_config,
setMocConfig,
setPfConfig,
setAsConfig,
setCeConfig,
setPeakConfig,
} = useUserDataStore()
const router = useRouter()
const transI18n = useTranslations("DataPage")
const {
setIsOpenImport,
isOpenImport,
setIsOpenMonster,
isOpenMonster,
setIsOpenConnect,
isOpenConnect,
setIsOpenExtra,
isOpenExtra
} = useModelStore()
const [importModal, setImportModal] = useState("enka");
const { isConnectPS, extraData } = useGlobalStore()
useEffect(() => {
const cookieLocale = document.cookie.split("; ")
.find((row) => row.startsWith("MYNEXTAPP_LOCALE"))
?.split("=")[1];
if (cookieLocale) {
if (!listCurrentLanguage.hasOwnProperty(cookieLocale)) {
setLocale("en")
} else {
setLocale(cookieLocale)
}
} else {
let browserLocale = navigator.language.slice(0, 2);
if (!listCurrentLanguage.hasOwnProperty(browserLocale)) {
browserLocale = "en"
}
setLocale(browserLocale);
document.cookie = `MYNEXTAPP_LOCALE=${browserLocale};`
router.refresh()
}
}, [router, setLocale])
const changeLocale = (newLocale: string) => {
setLocale(newLocale)
document.cookie = `MYNEXTAPP_LOCALE=${newLocale};`
router.refresh()
}
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.showModal();
}
};
// Close modal handler
const handleCloseModal = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.close()
}
};
// Handle ESC key to close modal
useEffect(() => {
if (!isOpenImport) {
handleCloseModal("import_modal");
return;
}
if (!isOpenMonster) {
handleCloseModal("monster_modal");
return;
}
if (!isOpenConnect) {
handleCloseModal("connect_modal");
return;
}
if (!isOpenExtra) {
handleCloseModal("extra_modal");
return;
}
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleCloseModal("connect_modal");
handleCloseModal("import_modal");
handleCloseModal("monster_modal");
handleCloseModal("extra_modal");
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
}, [isOpenImport, isOpenMonster, isOpenConnect, isOpenExtra]);
const handleImportDatabase = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
toast.error(transI18n("pleaseSelectAFile"))
return
}
if (!file.name.endsWith(".json") || file.type !== "application/json") {
toast.error(transI18n("fileMustBeAValidJsonFile"))
return
}
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
const parsed = micsSchema.parse(data)
setAvatars(parsed.avatars)
setBattleType(parsed.battle_type)
setMocConfig(parsed.moc_config)
setPfConfig(parsed.pf_config)
setAsConfig(parsed.as_config)
setCeConfig(parsed.ce_config)
setPeakConfig(parsed.peak_config)
toast.success(transI18n("importDatabaseSuccess"))
} catch {
toast.error(transI18n("fileMustBeAValidJsonFile"))
}
};
reader.readAsText(file);
}
};
const modalConfigs = [
{
id: "connect_modal",
title: transI18n("psConnection"),
isOpen: isOpenConnect,
onClose: () => {
setIsOpenConnect(false)
handleCloseModal("connect_modal")
},
content: <ConnectBar />
},
{
id: "import_modal",
title: transI18n("importSetting"),
isOpen: isOpenImport,
onClose: () => {
setIsOpenImport(false)
handleCloseModal("import_modal")
},
content: (
<>
{importModal === "enka" && <EnkaImport />}
{importModal === "freesr" && <FreeSRImport />}
</>
)
},
{
id: "monster_modal",
title: transI18n("monsterSetting"),
isOpen: isOpenMonster,
onClose: () => {
setIsOpenMonster(false)
handleCloseModal("monster_modal")
},
content: <MonsterBar />
},
{
id: "extra_modal",
title: transI18n("extraSetting"),
isOpen: isOpenExtra,
onClose: () => {
setIsOpenExtra(false)
handleCloseModal("extra_modal")
},
content: <ExtraSettingBar />
}
]
return (
<div className="navbar bg-base-100 shadow-md sticky top-0 z-50 px-3 py-1">
<div className="navbar-start">
{/* Mobile menu dropdown */}
<div className="dropdown">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm lg:hidden hover:bg-base-200 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</div>
<ul
tabIndex={0}
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-10 mt-3 w-52 p-2 shadow-md border border-base-200"
>
<li>
<details>
<summary className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium w-full">{transI18n("loadData")}</summary>
<ul className="p-2">
<li><a onClick={() => {
setImportModal("freesr")
setIsOpenImport(true)
handleShow("import_modal")
}}>{transI18n("freeSr")}</a></li>
<li>
<>
<input
type="file"
accept="application/json"
id="database-data-upload"
className="hidden"
onChange={handleImportDatabase}
/>
<button
onClick={() => document.getElementById('database-data-upload')?.click()}
>
{transI18n("database")}
</button>
</>
</li>
<li><a onClick={() => {
setImportModal("enka")
setIsOpenImport(true)
handleShow("import_modal")
}}>{transI18n("enka")}</a></li>
</ul>
</details>
</li>
<li>
<details>
<summary className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium">{transI18n("exportData")}</summary>
<ul className="p-2">
<li><a onClick={() => downloadJson("freesr-data",
converterToFreeSRJson(
avatars,
battle_type,
moc_config,
pf_config,
as_config,
ce_config,
peak_config
)
)}>{transI18n("freeSr")}</a></li>
<li><a onClick={() => downloadJson("database-data", {
avatars: avatars,
battle_type: battle_type,
moc_config: moc_config,
pf_config: pf_config,
as_config: as_config,
ce_config: ce_config,
peak_config: peak_config
})}>{transI18n("database")}</a></li>
</ul>
</details>
</li>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
onClick={() => {
setIsOpenConnect(true)
handleShow("connect_modal")
}}
>
{transI18n("connectSetting")}
</button>
</li>
<li>
<button
onClick={() => {
setIsOpenMonster(true)
handleShow("monster_modal")
}}
className="disabled px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
>
{transI18n("monsterSetting")}
</button>
</li>
{extraData && (
<li>
<button
onClick={() => {
setIsOpenExtra(true)
handleShow("extra_modal")
}}
className="disabled px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
>
{transI18n("extraSetting")}
</button>
</li>
)}
</ul>
</div>
{/* Logo */}
<a className="hidden sm:grid sm:grid-cols-1 items-start justify-items-center text-left gap-0 hover:scale-105 px-2">
<div className="flex items-center justify-center gap-2">
<Image src="/ff-srtool.png" alt="Logo" width={50} height={50} />
<div className="flex flex-col justify-center items-start">
<h1 className="text-xl font-bold">
<span className="text-emerald-500">Firefly Sr</span>
<span className="bg-clip-text text-transparent bg-linear-to-r from-emerald-400 via-orange-500 to-red-500">
Tools
</span>
</h1>
<p className="text-sm text-gray-500">By Kain</p>
</div>
</div>
</a>
</div>
{/* Desktop navigation */}
<div className="navbar-center hidden lg:flex">
<ul className="menu menu-horizontal gap-1">
<li>
<details>
<summary className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium">{transI18n("loadData")}</summary>
<ul className="p-2">
<li><a onClick={() => {
setImportModal("freesr")
setIsOpenImport(true)
handleShow("import_modal")
}}>{transI18n("freeSr")}</a></li>
<li>
<>
<input
type="file"
accept="application/json"
id="database-data-upload"
className="hidden"
onChange={handleImportDatabase}
/>
<button
onClick={() => document.getElementById('database-data-upload')?.click()}
>
{transI18n("database")}
</button>
</>
</li>
<li><a onClick={() => {
setImportModal("enka")
setIsOpenImport(true)
handleShow("import_modal")
}}>{transI18n("enka")}</a></li>
</ul>
</details>
</li>
<li>
<details>
<summary className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium">{transI18n("exportData")}</summary>
<ul className="p-2">
<li><a onClick={() => downloadJson("freesr-data",
converterToFreeSRJson(
avatars,
battle_type,
moc_config,
pf_config,
as_config,
ce_config,
peak_config
)
)}>{transI18n("freeSr")}</a></li>
<li><a onClick={() => downloadJson("database-data", {
avatars: avatars,
battle_type: battle_type,
moc_config: moc_config,
pf_config: pf_config,
as_config: as_config,
ce_config: ce_config,
peak_config: peak_config
})}>{transI18n("database")}</a></li>
</ul>
</details>
</li>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
onClick={() => {
setIsOpenConnect(true)
handleShow("connect_modal")
}}
>
{transI18n("connectSetting")}
</button>
</li>
<li>
<button
onClick={() => {
setIsOpenMonster(true)
handleShow("monster_modal")
}}
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
>
{transI18n("monsterSetting")}
</button>
</li>
{extraData && (
<li>
<button
onClick={() => {
setIsOpenExtra(true)
handleShow("extra_modal")
}}
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
>
{transI18n("extraSetting")}
</button>
</li>
)}
</ul>
</div>
{/* Right side items */}
<div className="navbar-end gap-2">
<div className="px-2">
<div className="flex items-center space-x-2 p-1.5 rounded-full shadow-md">
<div className={`hidden lg:block text-sm italic ${isConnectPS ? 'text-green-500' : 'text-red-500'}`}>
{isConnectPS ? transI18n("connected") : transI18n("unconnected")}
</div>
<div className={`w-3 h-3 rounded-full ${isConnectPS ? 'bg-green-500' : 'bg-red-500'}`}></div>
</div>
</div>
{/* Language selector - REFINED */}
<div className="dropdown dropdown-end">
<div className="flex items-center gap-1 border border-base-300 rounded text-sm px-1.5 py-0.5 hover:bg-base-200 cursor-pointer transition-all duration-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802" />
</svg>
<select
className="outline-none bg-base-200 cursor-pointer text-sm pr-0"
value={locale}
onChange={(e) => changeLocale(e.target.value)}
>
{Object.entries(listCurrentLanguage).map(([key, value]) => (
<option key={key} value={key}>{value}</option>
))}
</select>
</div>
</div>
<div title="Change Theme" className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm hover:bg-base-200 transition-all duration-200 px-2">
<svg
width={16}
height={16}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="h-4 w-4 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
<svg
width="10px"
height="10px"
className="ml-1 h-2 w-2 fill-current opacity-60"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2048 2048"
>
<path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z" />
</svg>
</div>
<div
tabIndex={0}
className="dropdown-content bg-base-100 text-base-content rounded-lg top-px overflow-y-auto border border-base-200 shadow-md mt-12"
>
<ul className="menu w-40 p-1">
{themes.map((theme) => (
<li
key={theme.label}
onClick={() => {
if (changeTheme) changeTheme(theme.label.toLowerCase());
}}
>
<button
className="gap-2 px-2 py-1.5 hover:bg-base-200 rounded text-sm transition-all duration-200"
data-set-theme={theme.label.toLowerCase()}
data-act-class="[&_svg]:visible"
>
<div
data-theme={theme.label.toLowerCase()}
className="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded p-1 shadow-sm"
>
<div className="bg-base-content size-1 rounded-full" />
<div className="bg-primary size-1 rounded-full" />
<div className="bg-secondary size-1 rounded-full" />
<div className="bg-accent size-1 rounded-full" />
</div>
<div className="text-sm">{theme.label}</div>
</button>
</li>
))}
</ul>
</div>
</div>
{/* GitHub Link */}
<Link
className='hidden sm:flex btn btn-ghost btn-sm btn-circle bg-white/20 hover:bg-white transition-all duration-200 items-center justify-center'
href={"https://github.com/AzenKain/Firefly-Srtools"}
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
</Link>
</div>
{modalConfigs.map(({ id, title, onClose, content }) => (
<dialog key={id} id={id} className="modal">
<div className="modal-box w-11/12 max-w-7xl max-h-[85vh] bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="sticky top-0 z-10">
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
onClick={onClose}
>
</motion.button>
</div>
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-400">
{title}
</h3>
</div>
{content}
</div>
</dialog>
))}
</div>
)
}

View File

@@ -0,0 +1,286 @@
"use client"
import useUserDataStore from "@/stores/userDataStore";
import { useEffect, useState } from "react";
import useCopyProfileStore from "@/stores/copyProfile";
import ProfileCard from "../card/profileCard";
import { AvatarProfileCardType, AvatarProfileStore } from "@/types";
import Image from "next/image";
import useListAvatarStore from "@/stores/avatarStore";
import { getNameChar } from "@/helper";
import useLocaleStore from "@/stores/localeStore";
import { useTranslations } from "next-intl";
import SelectCustomImage from "../select/customSelectImage";
export default function CopyImport() {
const { avatars, setAvatar } = useUserDataStore();
const { avatarSelected } = useListAvatarStore()
const { locale } = useLocaleStore()
const {
selectedProfiles,
listCopyAvatar,
avatarCopySelected,
setSelectedProfiles,
filterCopy,
setFilterCopy,
setAvatarCopySelected,
listElement,
listPath,
listRank,
setListElement,
setListPath,
setListRank
} = useCopyProfileStore()
const transI18n = useTranslations("DataPage")
const [message, setMessage] = useState({
type: "",
text: ""
})
const handleProfileToggle = (profile: AvatarProfileCardType) => {
if (selectedProfiles.some((selectedProfile) => selectedProfile.key === profile.key)) {
setSelectedProfiles(selectedProfiles.filter((selectedProfile) => selectedProfile.key !== profile.key));
return;
}
setSelectedProfiles([...selectedProfiles, profile]);
};
useEffect(() => {
setFilterCopy({
...filterCopy,
locale: locale,
path: Object.keys(listPath).filter((key) => listPath[key]),
element: Object.keys(listElement).filter((key) => listElement[key]),
rarity: Object.keys(listRank).filter((key) => listRank[key])
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listPath, listRank, listElement, locale, setFilterCopy])
const clearSelection = () => {
setSelectedProfiles([]);
setMessage({
type: "success",
text: transI18n("clearAll")
})
};
const selectAll = () => {
if (avatarCopySelected) {
setSelectedProfiles(avatars[avatarCopySelected?.id.toString()].profileList.map((profile, index) => {
if (!profile.lightcone?.item_id && Object.keys(profile.relics).length == 0) {
return null;
}
return {
key: index,
...profile,
} as AvatarProfileCardType
}).filter((profile) => profile !== null));
}
setMessage({
type: "success",
text: transI18n("selectAll")
})
};
const handleCopy = () => {
if (selectedProfiles.length === 0) {
setMessage({
type: "error",
text: transI18n("pleaseSelectAtLeastOneProfile")
});
return;
}
if (!avatarCopySelected) {
setMessage({
type: "error",
text: transI18n("noAvatarToCopySelected")
});
return;
}
if (!avatarSelected) {
setMessage({
type: "error",
text: transI18n("noAvatarSelected")
});
return;
}
const newListProfile = avatars[avatarCopySelected.id.toString()].profileList.map((profile) => {
if (!profile.lightcone?.item_id && Object.keys(profile.relics).length == 0) {
return null;
}
return {
...profile,
profile_name: profile.profile_name + ` - Copy: ${avatarCopySelected?.id}`,
} as AvatarProfileStore
}).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,
}
setAvatar(newAvatar);
setSelectedProfiles([]);
setMessage({
type: "success",
text: transI18n("copied")
});
};
return (
<div className="rounded-lg px-2 min-h-[60vh]">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-2">
<div className="mt-2 w-full">
<div className="flex items-start flex-col gap-2 mt-2 w-full">
<div>{transI18n("filter")}</div>
<div className="flex flex-wrap justify-start gap-5 md:gap-10 mt-2 w-full">
{/* Path */}
<div>
<div className="flex flex-wrap gap-2 justify-start items-center">
{Object.entries(listPath).map(([key], index) => (
<div
key={index}
onClick={() => {
setListPath({ ...listPath, [key]: !listPath[key] })
}}
className="w-[50px] h-[50px] hover:bg-gray-600 grid items-center justify-items-center rounded-md shadow-md cursor-pointer"
style={{
backgroundColor: listPath[key] ? "#374151" : "#6B7280"
}}>
<Image
src={`/icon/${key}.webp`}
alt={key}
className="h-[32px] w-[32px] object-contain rounded-md"
width={200}
height={200}
/>
</div>
))}
</div>
</div>
{/* Element */}
<div>
<div className="flex flex-wrap gap-2 justify-start items-center">
{Object.entries(listElement).map(([key], index) => (
<div
key={index}
onClick={() => {
setListElement({ ...listElement, [key]: !listElement[key] })
}}
className="w-[50px] h-[50px] hover:bg-gray-600 grid items-center justify-items-center rounded-md shadow-md cursor-pointer"
style={{
backgroundColor: listElement[key] ? "#374151" : "#6B7280"
}}>
<Image
src={`/icon/${key}.webp`}
alt={key}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md"
width={200}
height={200}
/>
</div>
))}
</div>
</div>
{/* Rank */}
<div>
<div className="flex flex-wrap gap-2 justify-end items-center">
{Object.entries(listRank).map(([key], index) => (
<div
key={index}
onClick={() => {
setListRank({ ...listRank, [key]: !listRank[key] })
}}
className="w-[50px] h-[50px] hover:bg-gray-600 grid items-center justify-items-center rounded-md shadow-md cursor-pointer"
style={{
backgroundColor: listRank[key] ? "#374151" : "#6B7280"
}}>
<div className="font-bold text-white h-[32px] w-[32px] text-center flex items-center justify-center">{key}*</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-2">
{listCopyAvatar.length > 0 && (
<div>
<div>{transI18n("characterName")}</div>
<SelectCustomImage
customSet={listCopyAvatar.map((avatar) => ({
value: avatar.id.toString(),
label: getNameChar(locale, avatar),
imageUrl: `https://api.hakush.in/hsr/UI/avatarshopicon/${avatar.id}.webp`
}))}
excludeSet={[]}
selectedCustomSet={avatarCopySelected?.id.toString() || ""}
placeholder="Character Select"
setSelectedCustomSet={(value) => setAvatarCopySelected(listCopyAvatar.find((avatar) => avatar.id.toString() === value) || null)}
/>
</div>
)}
</div>
</div>
{/* Selection Info */}
<div className="mt-6 flex items-center gap-4 bg-slate-800/50 p-4 rounded-lg">
<span className="text-blue-400 font-medium">
{transI18n("selectedProfiles")}: {selectedProfiles.length}
</span>
<button
onClick={clearSelection}
className="text-error/75 hover:text-error text-sm font-medium px-3 py-1 border border-error/75 rounded hover:bg-error/10 transition-colors cursor-pointer"
>
{transI18n("clearAll")}
</button>
<button
onClick={selectAll}
className="text-primary/75 hover:text-primary text-sm font-medium px-3 py-1 border border-primary/75 rounded hover:bg-primary/10 transition-colors cursor-pointer">
{transI18n("selectAll")}
</button>
{selectedProfiles.length > 0 && (
<button
onClick={handleCopy}
className="text-success/75 hover:text-success text-sm font-medium px-3 py-1 border border-success/75 rounded hover:bg-success/10 transition-colors cursor-pointer">
{transI18n("copy")}
</button>
)}
</div>
{message.type && message.text && (
<div className={(message.type == "error" ? "text-error" : "text-success") + " text-lg mt-2"} >{message.type == "error" ? "😭" : "🎉"} {message.text}</div>
)}
</div>
{/* 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) {
return null;
}
return (
<ProfileCard
key={index}
profile={{ ...profile, key: index }}
selectedProfile={selectedProfiles}
onProfileToggle={handleProfileToggle}
/>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,224 @@
"use client"
import { useState } from "react";
import CharacterInfoCard from "../card/characterInfoCard";
import useEnkaStore from "@/stores/enkaStore";
import { SendDataThroughProxy } from "@/lib/api/api";
import { CharacterInfoCardType, EnkaResponse } from "@/types";
import useUserDataStore from "@/stores/userDataStore";
import { converterOneEnkaDataToAvatarStore } from "@/helper";
import useModelStore from "@/stores/modelStore";
import { toast } from "react-toastify";
import { useTranslations } from "next-intl";
export default function EnkaImport() {
const {
uidInput,
setUidInput,
enkaData,
selectedCharacters,
setSelectedCharacters,
setEnkaData,
} = useEnkaStore();
const transI18n = useTranslations("DataPage")
const { avatars, setAvatar } = useUserDataStore();
const { setIsOpenImport } = useModelStore()
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
const handlerFetchData = async () => {
if (!uidInput) {
setError(transI18n("pleaseEnterUid"))
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
}))
setError("")
} else {
setError(transI18n("failedToFetchEnkaData"))
}
setIsLoading(false)
}
const handleCharacterToggle = (character: CharacterInfoCardType) => {
if (selectedCharacters.some((selectedCharacter) => selectedCharacter.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selectedCharacter) => selectedCharacter.key !== character.key));
return;
}
setSelectedCharacters([...selectedCharacters, character]);
};
const clearSelection = () => {
setSelectedCharacters([]);
};
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
}));
}
};
const handleImport = () => {
if (selectedCharacters.length === 0) {
setError(transI18n("pleaseSelectAtLeastOneCharacter"));
return;
}
if (!enkaData) {
setError(transI18n("noDataToImport"));
return;
}
setError("");
const listAvatars = { ...avatars }
const filterData = enkaData.detailInfo.avatarDetailList.filter((character) => selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatarId))
filterData.forEach((character) => {
const newAvatar = { ...listAvatars[character.avatarId.toString()] }
if (Object.keys(newAvatar).length !== 0) {
newAvatar.level = character.level ?? 0
newAvatar.promotion = character.promotion ?? 0
newAvatar.data = {
rank: character.rank ?? 0,
skills: character.skillTreeList.reduce((acc, skill) => {
acc[skill.pointId] = skill.level;
return acc;
}, {} as Record<string, number>),
}
const newProfile = converterOneEnkaDataToAvatarStore(character, newAvatar.profileList.length)
if (newProfile) {
newAvatar.profileList.push(newProfile)
newAvatar.profileSelect = newAvatar.profileList.length - 1
}
setAvatar(newAvatar)
}
})
setIsOpenImport(false)
toast.success(transI18n("importEnkaDataSuccess"))
};
return (
<div className="bg-slate-900 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-white text-2xl font-bold mb-6">HSR UID</h1>
<div className="flex gap-4 items-center mb-6">
<input
type="text"
value={uidInput}
onChange={(e) => setUidInput(e.target.value)}
className="bg-slate-800 text-white px-4 py-3 rounded-lg border border-slate-700 flex-1 max-w-md focus:outline-none focus:border-blue-500"
placeholder="Enter UID"
/>
<button onClick={handlerFetchData} className="btn btn-success rounded-lg transition-colors">
{transI18n("getData")}
</button>
{selectedCharacters.length > 0 && (
<button onClick={handleImport} className="btn btn-primary rounded-lg transition-colors">
{transI18n("import")}
</button>
)}
</div>
{Error && (
<div className="text-error text-base mt-2">😭 {Error}</div>
)}
{/* Player Info */}
{enkaData && (
<div className="text-white space-y-2">
<div className="text-lg">Name: {enkaData.detailInfo.nickname}</div>
<div className="text-lg">UID: {enkaData.detailInfo.uid}</div>
<div className="text-lg">Level: {enkaData.detailInfo.level}</div>
</div>
)}
{/* Selection Info */}
<div className="mt-6 flex items-center gap-4 bg-slate-800/50 p-4 rounded-lg">
<span className="text-blue-400 font-medium">
{transI18n("selectedCharacters")}: {selectedCharacters.length}
</span>
<button
onClick={clearSelection}
className="text-error/75 hover:text-error text-sm font-medium px-3 py-1 border border-error/75 rounded hover:bg-error/10 transition-colors cursor-pointer"
>
{transI18n("clearAll")}
</button>
<button onClick={selectAll} className="text-primary/75 hover:text-primary text-sm font-medium px-3 py-1 border border-primary/75 rounded hover:bg-primary/10 transition-colors cursor-pointer">
{transI18n("selectAll")}
</button>
</div>
</div>
{isLoading && (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
)}
{/* Character Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{enkaData?.detailInfo.avatarDetailList.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
}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,235 @@
"use client"
import useFreeSRStore from "@/stores/freesrStore";
import useModelStore from "@/stores/modelStore";
import useUserDataStore from "@/stores/userDataStore";
import { CharacterInfoCardType } from "@/types";
import { useState } from "react";
import CharacterInfoCard from "../card/characterInfoCard";
import { freeSrJsonSchema } from "@/zod";
import { toast } from "react-toastify";
import { converterOneFreeSRDataToAvatarStore } from "@/helper";
import { useTranslations } from "next-intl";
export default function FreeSRImport() {
const { avatars, setAvatar } = useUserDataStore();
const { setIsOpenImport } = useModelStore()
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
const { freeSRData, setFreeSRData, selectedCharacters, setSelectedCharacters } = useFreeSRStore()
const transI18n = useTranslations("DataPage")
const handleCharacterToggle = (character: CharacterInfoCardType) => {
if (selectedCharacters.some((selectedCharacter) => selectedCharacter.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selectedCharacter) => selectedCharacter.key !== character.key));
return;
}
setSelectedCharacters([...selectedCharacters, character]);
};
const clearSelection = () => {
setSelectedCharacters([]);
};
const selectAll = () => {
if (freeSRData) {
setSelectedCharacters(Object.values(freeSRData?.avatars).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 handlerReadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true)
const file = event.target.files?.[0];
if (!file) {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("pleaseSelectAFile"))
setIsLoading(false)
return
}
if (!file.name.endsWith(".json") || file.type !== "application/json") {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
setIsLoading(false)
return
}
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
const parsed = freeSrJsonSchema.parse(data)
setFreeSRData(parsed)
setError("")
setSelectedCharacters(Object.values(parsed?.avatars || {}).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
}));
} catch {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
}
};
reader.readAsText(file);
setIsLoading(false)
}
setIsLoading(false)
};
const handleImport = () => {
if (selectedCharacters.length === 0) {
setError(transI18n("pleaseSelectAtLeastOneCharacter"));
return;
}
if (!freeSRData) {
setError(transI18n("noDataToImport"));
return;
}
setError("");
const listAvatars = { ...avatars }
const filterData = Object.values(freeSRData?.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) {
newAvatar.level = character.level
newAvatar.promotion = character.promotion
newAvatar.data = {
rank: character.data.rank ?? 0,
skills: character.data.skills
}
const newProfile = converterOneFreeSRDataToAvatarStore(freeSRData, newAvatar.profileList.length, character.avatar_id)
if (newProfile) {
newAvatar.profileList.push(newProfile)
newAvatar.profileSelect = newAvatar.profileList.length - 1
}
setAvatar(newAvatar)
}
})
setIsOpenImport(false)
toast.success(transI18n("importFreeSRDataSuccess"))
}
return (
<div className="bg-slate-900 p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<h1 className="text-white text-2xl font-bold mb-6">{transI18n("freeSRImport")}</h1>
<div className="flex gap-4 items-center mb-6">
<fieldset className="fieldset">
<legend className="fieldset-legend">{transI18n("pickAFile")}</legend>
<input type="file" accept=".json" className="file-input file-input-info" onChange={handlerReadFile} />
<label className="label">{transI18n("onlySupportFreeSRJsonFile")}</label>
</fieldset>
{selectedCharacters.length > 0 && (
<button onClick={handleImport} className="btn btn-primary rounded-lg transition-colors">
{transI18n("import")}
</button>
)}
</div>
{Error && (
<div className="text-error text-base mt-2">😭 {Error}</div>
)}
{/* Selection Info */}
<div className="mt-6 flex items-center gap-4 bg-slate-800/50 p-4 rounded-lg">
<span className="text-blue-400 font-medium">
{transI18n("selectedCharacters")}: {selectedCharacters.length}
</span>
<button
onClick={clearSelection}
className="text-error/75 hover:text-error text-sm font-medium px-3 py-1 border border-error/75 rounded hover:bg-error/10 transition-colors cursor-pointer"
>
{transI18n("clearAll")}
</button>
<button onClick={selectAll} className="text-primary/75 hover:text-primary text-sm font-medium px-3 py-1 border border-primary/75 rounded hover:bg-primary/10 transition-colors cursor-pointer">
{transI18n("selectAll")}
</button>
</div>
</div>
{isLoading && (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
)}
{/* Character Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Object.values(freeSRData?.avatars || {}).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 (
<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
}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,144 @@
"use client"
import { useEffect } from "react"
import Image from "next/image";
import useLocaleStore from "@/stores/localeStore"
import useLightconeStore from "@/stores/lightconeStore";
import LightconeCard from "../card/lightconeCard";
import useUserDataStore from "@/stores/userDataStore";
import useAvatarStore from "@/stores/avatarStore";
import useModelStore from "@/stores/modelStore";
import { useTranslations } from "next-intl";
export default function LightconeBar() {
const { locale } = useLocaleStore()
const {
listLightcone,
filter,
setFilter,
defaultFilter,
listPath,
listRank,
setListPath,
setListRank
} = useLightconeStore()
const { setAvatar, avatars } = useUserDataStore()
const { avatarSelected } = useAvatarStore()
const { setIsOpenLightcone } = useModelStore()
const transI18n = useTranslations("DataPage")
useEffect(() => {
const newListPath: Record<string, boolean> = { "knight": false, "mage": false, "priest": false, "rogue": false, "shaman": false, "warlock": false, "warrior": false, "memory": false }
const newListRank: Record<string, boolean> = { "3": false, "4": false, "5": false }
for (const path of defaultFilter.path) {
if (path in newListPath) {
newListPath[path] = true
}
}
for (const rarity of defaultFilter.rarity) {
if (rarity in newListRank) {
newListRank[rarity] = true
}
}
setListPath(newListPath)
setListRank(newListRank)
}, [defaultFilter, setListPath, setListRank])
useEffect(() => {
setFilter({
...filter,
locale: locale,
path: Object.keys(listPath).filter((key) => listPath[key]),
rarity: Object.keys(listRank).filter((key) => listRank[key])
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [listPath, listRank, locale])
return (
<div>
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-400">
{transI18n("lightConeSetting")}
</h3>
</div>
<div className="mt-2 w-full">
<div className="flex items-start flex-col gap-2">
<div>Search</div>
<input
value={filter.name}
onChange={(e) => setFilter({ ...filter, name: e.target.value, locale: locale })}
type="text" placeholder="LightCone Name" className="input input-accent mt-1 w-full"
/>
</div>
<div className="flex items-start flex-col gap-2 mt-2 w-full">
<div>Filter</div>
<div className="flex flex-row flex-wrap justify-between mt-1 w-full">
{/* Nhóm 1 - Path icons */}
<div className="flex flex-wrap mb-1 mx-1 gap-2">
{Object.keys(listPath).map((key, index) => (
<div
key={index}
onClick={() => {
setListPath({ ...listPath, [key]: !listPath[key] })
}}
className="h-[38px] w-[38px] md:h-[50px] md:w-[50px] hover:bg-gray-600 grid place-items-center rounded-md shadow-lg cursor-pointer"
style={{
backgroundColor: listPath[key] ? "#374151" : "#6B7280"
}}
>
<Image
src={`/icon/${key}.webp`}
alt={key}
className="h-[28px] w-[28px] md:h-[32px] md:w-[32px] object-contain rounded-md"
width={200}
height={200}
/>
</div>
))}
</div>
{/* Nhóm 2 - Rank icons */}
<div className="flex flex-wrap mb-1 mx-1 gap-2">
{Object.keys(listRank).map((key, index) => (
<div
key={index}
onClick={() => {
setListRank({ ...listRank, [key]: !listRank[key] })
}}
className="h-[38px] w-[38px] md:h-[50px] md:w-[50px] hover:bg-gray-600 grid place-items-center rounded-md shadow-lg cursor-pointer"
style={{
backgroundColor: listRank[key] ? "#374151" : "#6B7280"
}}
>
<div className="font-bold text-white h-[32px] w-[32px] text-center flex items-center justify-center">
{key}*
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-3 md:grid-cols-6 lg:grid-cols-8 mt-2 gap-2">
{listLightcone.map((item, index) => (
<div key={index} onClick={() => {
if (avatarSelected) {
const avatar = avatars[avatarSelected.id]
avatar.profileList[avatar.profileSelect].lightcone = {
level: 80,
item_id: Number(item.id),
rank: 1,
promotion: 6
}
setAvatar({ ...avatar })
setIsOpenLightcone(false)
}
}}>
<LightconeCard data={item} />
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,350 @@
"use client"
import { useEffect, useMemo } from "react";
import SelectCustomText from "../select/customSelectText";
import useEventStore from "@/stores/eventStore";
import { getLocaleName, replaceByParam } from "@/helper";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";
import useMonsterStore from "@/stores/monsterStore";
import Image from "next/image";
import { MonsterStore } from "@/types";
import useMazeStore from "@/stores/mazeStore";
import { useTranslations } from "next-intl";
import { listCurrentLanguageApi } from "@/constant/constant";
export default function AsBar() {
const { ASEvent, mapASInfo } = useEventStore()
const { listMonster } = useMonsterStore()
const { locale } = useLocaleStore()
const {
as_config,
setAsConfig
} = useUserDataStore()
const { AS } = useMazeStore()
const transI18n = useTranslations("DataPage")
const challengeSelected = useMemo(() => {
return mapASInfo[as_config.event_id.toString()]?.Level.find((as) => as.Id === as_config.challenge_id)
}, [as_config, mapASInfo])
const eventSelected = useMemo(() => {
return mapASInfo[as_config.event_id.toString()]
}, [as_config, mapASInfo])
const buffList = useMemo(() => {
const challenge = AS[as_config.event_id.toString()];
if (!challenge) return { buffList: [], buffId: [] };
if (as_config.floor_side === "Upper" || as_config.floor_side === "Upper -> Lower") {
return {
buffList: eventSelected?.BuffList1 ?? [],
buffId: challenge.buff_1 ?? [],
};
}
if (as_config.floor_side === "Lower" || as_config.floor_side === "Lower -> Upper") {
return {
buffList: eventSelected?.BuffList2 ?? [],
buffId: challenge.buff_2 ?? [],
};
}
return { buffList: [], buffId: [] };
}, [AS, as_config.event_id, as_config.floor_side, eventSelected?.BuffList1, eventSelected?.BuffList2]);
useEffect(() => {
if (!challengeSelected || as_config.event_id === 0 || as_config.challenge_id === 0) return
const newBattleConfig = structuredClone(as_config)
newBattleConfig.cycle_count = 0
newBattleConfig.blessings = []
if (as_config.buff_id !== 0) {
newBattleConfig.blessings.push({
id: as_config.buff_id,
level: 1
})
}
if (AS[as_config.challenge_id.toString()]) {
newBattleConfig.blessings.push({
id: Number(AS[as_config.challenge_id.toString()].maze_buff),
level: 1
})
}
newBattleConfig.monsters = []
newBattleConfig.stage_id = 0
if ((as_config.floor_side === "Upper" || as_config.floor_side === "Upper -> Lower")
&& challengeSelected.EventIDList1.length > 0) {
newBattleConfig.stage_id = challengeSelected.EventIDList1[0].StageID
for (const wave of challengeSelected.EventIDList1[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList1[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
if ((as_config.floor_side === "Lower" || as_config.floor_side === "Lower -> Upper")
&& challengeSelected.EventIDList2.length > 0) {
newBattleConfig.stage_id = challengeSelected.EventIDList2[0].StageID
for (const wave of challengeSelected.EventIDList2[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList2[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
if (as_config.floor_side === "Lower -> Upper"
&& challengeSelected.EventIDList1.length > 0) {
for (const wave of challengeSelected.EventIDList1[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList1[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
} else if (as_config.floor_side === "Upper -> Lower"
&& challengeSelected.EventIDList2.length > 0) {
for (const wave of challengeSelected.EventIDList2[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList2[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
setAsConfig(newBattleConfig)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
challengeSelected,
as_config.event_id,
as_config.challenge_id,
as_config.floor_side,
as_config.buff_id,
mapASInfo,
AS,
])
if (!ASEvent) return null
return (
<div className="py-8 relative">
{/* Title Card */}
<div className="rounded-xl p-4 mb-2 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={ASEvent.filter(as => as.lang.get(listCurrentLanguageApi[locale])).map((as) => ({
id: as.id,
name: getLocaleName(locale, as),
time: `${as.begin} - ${as.end}`,
}))}
excludeSet={[]}
selectedCustomSet={as_config.event_id.toString()}
placeholder={transI18n("selectASEvent")}
setSelectedCustomSet={(id) => setAsConfig({
...as_config,
event_id: Number(id),
challenge_id: mapASInfo[Number(id)]?.Level.slice(-1)[0]?.Id || 0,
buff_id: 0
})}
/>
</div>
{/* Settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center gap-2">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("floor")}:{" "}</span>
</label>
<select
value={as_config.challenge_id}
className="select select-success"
onChange={(e) => setAsConfig({ ...as_config, challenge_id: Number(e.target.value) })}
>
<option value={0} disabled={true}>{transI18n("selectFloor")}</option>
{mapASInfo[as_config.event_id.toString()]?.Level.map((as) => (
<option key={as.Id} value={as.Id}>{as.Id % 10}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("side")}:{" "}</span>
</label>
<select
value={as_config.floor_side}
className="select select-success"
onChange={(e) => setAsConfig({ ...as_config, floor_side: e.target.value })}
>
<option value={0} disabled={true}>{transI18n("selectSide")}</option>
<option value="Upper">{transI18n("upper")}</option>
<option value="Lower">{transI18n("lower")}</option>
<option value="Upper -> Lower">{transI18n("upperToLower")}</option>
<option value="Lower -> Upper">{transI18n("lowerToUpper")}</option>
</select>
</div>
</div>
{eventSelected && (
<div className="mb-4 w-full">
<SelectCustomText
customSet={
Array.isArray(buffList?.buffList) && Array.isArray(buffList?.buffId)
? buffList.buffList.map((buff, index) => ({
id: buffList.buffId?.[index]?.toString() || "",
name: buff?.Name || "",
description: replaceByParam(buff?.Desc || "", buff?.Param || []),
}))
: []
}
excludeSet={[]}
selectedCustomSet={as_config?.buff_id?.toString()}
placeholder={transI18n("selectBuff")}
setSelectedCustomSet={(id) => setAsConfig({ ...as_config, buff_id: Number(id) })}
/>
</div>
)}
{/* Turbulence Buff */}
<div className="bg-base-200/20 rounded-lg p-4 border border-purple-500/20">
<h2 className="text-2xl font-bold mb-2 text-info">{transI18n("turbulenceBuff")}</h2>
{eventSelected && eventSelected.Buff?.Name ? (
<div
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceByParam(
eventSelected.Buff?.Desc || "",
eventSelected.Buff?.Param || []
)
}}
/>
) : (
<div className="text-base">{transI18n("noTurbulenceBuff")}</div>
)}
</div>
</div>
{/* Enemy Waves */}
{(as_config?.challenge_id ?? 0) !== 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* First Half */}
<div className="rounded-xl p-4 mt-2 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{transI18n("firstHalfEnemies")}</h2>
{challengeSelected && challengeSelected?.EventIDList1?.length > 0 && challengeSelected?.EventIDList1[0].MonsterList.map((wave, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold mb-t">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.values(wave).map((waveValue, enemyIndex) => (
<div
key={enemyIndex}
className="rounded-xl p-2 border border-white/10 shadow-md hover:border-white/20 hover:shadow-lg transition"
>
<div className="flex items-center space-x-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonster.find((monster) => monster.child.includes(waveValue))?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonster.find((monster) => monster.child.includes(waveValue))?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>}
</div>
<div className="flex flex-col">
<div className="text-sm font-semibold">Lv. {challengeSelected?.EventIDList1[0].Level}</div>
<div className="flex items-center space-x-1 mt-1">
{listMonster
.find((monster) => monster.child.includes(waveValue))
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Second Half */}
<div className="rounded-xl p-4 mt-2 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{transI18n("secondHalfEnemies")}</h2>
{challengeSelected && challengeSelected?.EventIDList2?.length > 0 && challengeSelected?.EventIDList2[0].MonsterList.map((wave, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold mb-t">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.values(wave).map((waveValue, enemyIndex) => (
<div
key={enemyIndex}
className="rounded-xl p-2 border border-white/10 shadow-md hover:border-white/20 hover:shadow-lg transition"
>
<div className="flex items-center space-x-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonster.find((monster) => monster.child.includes(waveValue))?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonster.find((monster) => monster.child.includes(waveValue))?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>}
</div>
<div className="flex flex-col">
<div className="text-sm font-semibold">Lv. {challengeSelected?.EventIDList1[0].Level}</div>
<div className="flex items-center space-x-1 mt-1">
{listMonster
.find((monster) => monster.child.includes(waveValue))
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,558 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import {
Plus,
Trash2,
ChevronUp,
ChevronDown,
Search,
CopyPlus,
} from "lucide-react";
import useMazeStore from "@/stores/mazeStore";
import useUserDataStore from "@/stores/userDataStore";
import useMonsterStore from "@/stores/monsterStore";
import useLocaleStore from "@/stores/localeStore";
import { getLocaleName } from "@/helper";
import Image from "next/image";
import { MonsterBasic } from "@/types";
import { useTranslations } from "next-intl";
import { listCurrentLanguageApi } from "@/constant/constant";
import useGlobalStore from "@/stores/globalStore";
export default function CeBar() {
const [searchTerm, setSearchTerm] = useState("");
const [showSearchWaveId, setShowSearchWaveId] = useState<number | null>(null);
const { Stage } = useMazeStore()
const { ce_config, setCeConfig } = useUserDataStore()
const { mapMonsterInfo } = useMonsterStore()
const { locale } = useLocaleStore()
const transI18n = useTranslations("DataPage")
const [showSearchStage, setShowSearchStage] = useState(false)
const [stageSearchTerm, setStageSearchTerm] = useState("")
const [stagePage, setStagePage] = useState(1)
const { extraData, setExtraData } = useGlobalStore()
const pageSize = 30
const pageSizeMonsters = 30
const [monsterPage, setMonsterPage] = useState(1)
const listMonsterDetail = useMemo(() => {
const result: MonsterBasic[] = []
for (const monster of Object.values(mapMonsterInfo)) {
for (const monsterChild of monster.Child) {
result.push({
id: monsterChild.Id.toString(),
rank: monster.Rank,
camp: null,
icon: monster.ImagePath,
weak: monsterChild.StanceWeakList,
desc: monster.Desc,
child: [],
lang: new Map<string, string>([
[listCurrentLanguageApi[locale], monster.Name],
]),
})
}
}
return result
}, [mapMonsterInfo, locale])
const filteredMonsters = useMemo(() => {
const newlistMonster = new Set<MonsterBasic>()
for (const monster of listMonsterDetail) {
if (getLocaleName(locale, monster).toLowerCase().includes(searchTerm.toLowerCase())) {
newlistMonster.add(monster)
}
if (monster.id.toLowerCase().includes(searchTerm.toLowerCase())) {
newlistMonster.add(monster)
}
}
return Array.from(newlistMonster)
}, [listMonsterDetail, locale, searchTerm]);
const paginatedMonsters = useMemo(() =>
filteredMonsters.slice((monsterPage - 1) * pageSizeMonsters, monsterPage * pageSizeMonsters),
[filteredMonsters, monsterPage]
)
const hasMoreMonsterPages = useMemo(
() => monsterPage * pageSizeMonsters < filteredMonsters.length,
[monsterPage, filteredMonsters]
)
useEffect(() => {
setMonsterPage(1) // reset về trang 1 khi searchTerm thay đổi
}, [searchTerm])
const stageList = useMemo(() => Object.values(Stage).map((stage) => ({
id: stage.stage_id.toString(),
name: `${stage.stage_type} (${stage.stage_id})`,
})), [Stage])
const filteredStages = useMemo(() => stageList.filter((s) =>
s.name.toLowerCase().includes(stageSearchTerm.toLowerCase())
), [stageList, stageSearchTerm])
const paginatedStages = useMemo(() => filteredStages.slice(
(stagePage - 1) * pageSize,
stagePage * pageSize
), [filteredStages, stagePage, pageSize])
const hasMorePages = useMemo(() => stagePage * pageSize < filteredStages.length, [stagePage, filteredStages])
useEffect(() => {
setStagePage(1)
}, [stageSearchTerm])
useEffect(() => {
if (!ce_config) return
if (!extraData || !extraData.theory_craft?.mode) return
const newExtraData = structuredClone(extraData)
if (!newExtraData.theory_craft.hp) {
newExtraData.theory_craft.hp = {}
}
for (let i = 0; i < ce_config.monsters.length; i++) {
const waveKey = (i + 1).toString()
if (!newExtraData.theory_craft.hp[waveKey]) {
newExtraData.theory_craft.hp[waveKey] = []
}
for (let j = 0; j < ce_config.monsters[i].length; j++) {
if (newExtraData.theory_craft.hp[waveKey][j] === undefined) {
newExtraData.theory_craft.hp[waveKey][j] = 0
}
}
}
setExtraData(newExtraData)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ce_config])
return (
<div className="z-4 py-8 h-full w-full" onClick={() => {
setShowSearchWaveId(null)
setShowSearchStage(false)
}}>
<div className="mb-4 w-full relative">
<div className="flex items-center justify-center gap-2">
<button
className="btn btn-outline w-full text-left flex items-center gap-2"
onClick={(e) => {
e.stopPropagation()
setShowSearchStage(true)
}}
>
<Search className="w-6 h-6" />
<span className="text-left"> {transI18n("stage")}: {stageList.find((s) => s.id === ce_config.stage_id.toString())?.name || transI18n("selectStage")}</span>
</button>
</div>
{showSearchStage && (
<div onClick={(e) => e.stopPropagation()} className="absolute top-full mt-2 w-full z-50 border bg-base-200 border-slate-600 rounded-lg p-4 shadow-lg">
<div className="flex items-center gap-2 mb-2">
<label className="input w-full">
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search" className="grow"
placeholder={transI18n("searchStage")}
value={stageSearchTerm}
onChange={(e) => setStageSearchTerm(e.target.value)}
autoFocus
/>
</label>
</div>
<div className="max-h-60 overflow-y-auto space-y-1">
{paginatedStages.length > 0 ? (
<>
{paginatedStages.map((stage) => (
<div
key={stage.id}
className="p-2 hover:bg-primary/20 rounded cursor-pointer"
onClick={() => {
if (ce_config.stage_id !== Number(stage.id)) {
setCeConfig({ ...ce_config, stage_id: Number(stage.id), cycle_count: 30 })
}
setShowSearchStage(false)
setStageSearchTerm("")
}}
>
{stage.name}
</div>
))}
</>
) : (
<div className="text-sm text-center py-4">{transI18n("noStageFound")}</div>
)}
</div>
{paginatedStages.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<button
disabled={stagePage === 1}
className="btn btn-sm btn-outline btn-success mt-2"
onClick={(e) => {
e.stopPropagation()
setStagePage(stagePage - 1)
}}
>
{transI18n("previous")}
</button>
<button
disabled={!hasMorePages}
className="btn btn-sm btn-outline btn-success mt-2"
onClick={(e) => {
e.stopPropagation()
setStagePage(stagePage + 1)
}}
>
{transI18n("next")}
</button>
</div>
)}
</div>
)}
</div>
<div className="relative max-w-7xl mx-auto space-y-6">
{ce_config.monsters.map((wave, waveIndex) => (
<div key={waveIndex} className="card border border-slate-700/50 ">
<div className="card-body p-6">
<div className="flex items-center flex-wrap justify-between mb-4">
<h2 className="text-xl font-bold text-white">{transI18n("wave")} {waveIndex + 1}</h2>
<div className="flex gap-2">
<button
disabled={waveIndex === 0}
onClick={(e) => {
e.stopPropagation()
const newCeConfig = structuredClone(ce_config)
const waves = newCeConfig.monsters
const temp = waves[waveIndex - 1]
waves[waveIndex - 1] = waves[waveIndex]
waves[waveIndex] = temp
setCeConfig(newCeConfig)
}}
className="btn btn-sm btn-success"
>
<ChevronUp className="w-4 h-4" />
</button>
<button
disabled={waveIndex === ce_config.monsters.length - 1}
onClick={(e) => {
e.stopPropagation()
const newCeConfig = structuredClone(ce_config)
const waves = newCeConfig.monsters
const temp = waves[waveIndex + 1]
waves[waveIndex + 1] = waves[waveIndex]
waves[waveIndex] = temp
setCeConfig(newCeConfig)
}}
className="btn btn-sm btn-success">
<ChevronDown className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
const newCeConfig = structuredClone(ce_config)
const waves = newCeConfig.monsters
const temp = waves[waveIndex]
newCeConfig.monsters.push([...temp])
setCeConfig(newCeConfig)
}}
className="btn btn-sm btn-success">
<CopyPlus className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation()
const newCeConfig = structuredClone(ce_config)
newCeConfig.monsters.splice(waveIndex, 1)
setCeConfig(newCeConfig)
}}
className="btn btn-sm btn-error">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 w-full h-full">
{wave.map((member, memberIndex) => (
<div key={memberIndex} className="relative group z-7 w-full h-full">
<div className="card border hover:border-slate-500 transition-colors w-full h-full">
<div className="card-body p-4">
<button
className="btn btn-xs btn-success absolute -top-2 right-12 opacity-50 group-hover:opacity-100 transition-opacity"
onClick={() => {
const newCeConfig = structuredClone(ce_config)
newCeConfig.monsters[waveIndex].push({
monster_id: Number(member.monster_id),
level: member.level,
amount: member.amount,
})
setCeConfig(newCeConfig)
}}
>
<CopyPlus className="w-3 h-3" />
</button>
<button
className="btn btn-xs btn-error absolute -top-2 right-2 opacity-50 group-hover:opacity-100 transition-opacity"
onClick={() => {
const newCeConfig = structuredClone(ce_config)
newCeConfig.monsters[waveIndex].splice(memberIndex, 1)
setCeConfig(newCeConfig)
}}
>
<Trash2 className="w-3 h-3" />
</button>
<div className="flex justify-center">
{listMonsterDetail.find((monster2) => monster2.id === member.monster_id.toString())?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonsterDetail.find((monster2) => monster2.id === member.monster_id.toString())?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className=" object-contain w-20 h-20 overflow-hidden"
/>}
</div>
<div className="flex flex-wrap justify-center gap-1 mb-2">
{listMonsterDetail
.find((monster) => monster.id === member.monster_id.toString())
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
<div className="text-center flex flex-col items-center justify-center">
<div className="text-sm font-medium">
{getLocaleName(locale, listMonsterDetail.find((monster) => monster.id === member.monster_id.toString()))} {`(${member.monster_id})`}
</div>
<div className="flex items-center gap-1 mt-1 mx-2">
<span className="text-sm">LV.</span>
<input
type="number"
className="text-center input input-sm"
value={member.level}
onChange={(e) => {
const val = Number(e.target.value)
if (isNaN(val) || val < 1 || val > 95) return
if (ce_config.monsters[waveIndex][memberIndex].level === val) return
const newCeConfig = structuredClone(ce_config)
newCeConfig.monsters[waveIndex][memberIndex].level = val
setCeConfig(newCeConfig)
}}
/>
</div>
{(extraData?.theory_craft?.mode === true && (
<div className="flex items-center gap-1 mt-1 mx-2">
<span className="text-sm">HP</span>
<input
type="number"
className="text-center input input-sm"
value={extraData?.theory_craft?.hp?.[(waveIndex + 1).toString()]?.[memberIndex] || 0}
onChange={(e) => {
const val = Number(e.target.value)
if (isNaN(val) || val < 0) return
const newData = structuredClone(extraData)
if (!newData?.theory_craft?.hp?.[(waveIndex + 1).toString()]) {
newData.theory_craft.hp[(waveIndex + 1).toString()] = []
}
newData.theory_craft.hp[(waveIndex + 1).toString()][memberIndex] = val
setExtraData(newData)
}}
/>
</div>
))}
</div>
</div>
</div>
</div>
))}
{/* Add Member Button + Search */}
<div
className="relative flex items-start justify-center w-full h-full"
onClick={(e) => e.stopPropagation()}
>
<button
className="btn btn-outline btn-primary w-full h-full border-dashed"
onClick={(e) => {
e.stopPropagation();
if (showSearchWaveId === waveIndex) {
setShowSearchWaveId(null)
return
}
setShowSearchWaveId(waveIndex)
}}
>
<Plus className="w-8 h-8" />
</button>
{showSearchWaveId === waveIndex && (
<div className="absolute top-full mt-2 w-72 border bg-base-200 border-slate-600 rounded-lg p-4 shadow-lg z-50">
<div className="flex items-center gap-2 mb-2">
<label className="input w-full">
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input
type="search" className="grow"
placeholder={transI18n("searchMonster")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
</label>
</div>
<div className="max-h-60 overflow-y-auto space-y-1">
{paginatedMonsters.length > 0 ? (
paginatedMonsters.map((monster) => (
<div
key={monster.id}
className="flex items-center gap-2 p-2 hover:bg-success/40 rounded cursor-pointer"
onClick={() => {
const newCeConfig = structuredClone(ce_config)
newCeConfig.monsters[waveIndex].push({
monster_id: Number(monster.id),
level: 95,
amount: 1,
})
setCeConfig(newCeConfig)
setShowSearchWaveId(null)
}}
>
<div className="relative w-8 h-8 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonsterDetail.find((monster2) => monster2.id === monster.id)?.icon?.split("/")?.pop()?.replace(".png", "") && (
<Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonsterDetail.find((monster2) => monster2.id === monster.id)?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>
)}
</div>
<span>{getLocaleName(locale, monster)} {`(${monster.id})`}</span>
</div>
))
) : (
<div className="text-sm text-center py-4">
{transI18n("noMonstersFound")}
</div>
)}
</div>
{filteredMonsters.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mt-2">
<button
disabled={monsterPage === 1}
className="btn btn-sm btn-outline btn-success"
onClick={(e) => {
e.stopPropagation()
setMonsterPage(monsterPage - 1)
}}
>
{transI18n("previous")}
</button>
<button
disabled={!hasMoreMonsterPages}
className="btn btn-sm btn-outline btn-success"
onClick={(e) => {
e.stopPropagation()
setMonsterPage(monsterPage + 1)
}}
>
{transI18n("next")}
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
))}
{/* Add New Wave Button */}
<div className="card border border-slate-700/50 border-dashed">
<div className="card-body p-8">
<button
onClick={() => {
const newCeConfig = structuredClone(ce_config)
newCeConfig.monsters.push([])
setCeConfig(newCeConfig)
}}
className="btn btn-primary btn-lg w-full">
<Plus className="w-6 h-6 mr-2" />
{transI18n("addNewWave")}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import MocBar from "./moc";
import useUserDataStore from "@/stores/userDataStore";
import PfBar from "./pf";
import Image from "next/image";
import AsBar from "./as";
import { useTranslations } from "next-intl";
import CeBar from "./ce";
import PeakBar from "./peak";
export default function MonsterBar() {
const { battle_type, setBattleType } = useUserDataStore()
const transI18n = useTranslations("DataPage")
const navItems = [
{ name: transI18n("memoryOfChaos"), icon: 'AbyssIcon01', value: 'MOC' },
{ name: transI18n("pureFiction"), icon: 'ChallengeStory', value: 'PF' },
{ name: transI18n("apocalypticShadow"), icon: 'ChallengeBoss', value: 'AS' },
{ name: transI18n("anomalyArbitration"), icon: 'ChallengePeakIcon', value: 'PEAK' },
{ name: transI18n("customEnemy"), icon: 'MonsterIcon', value: 'CE' },
{ name: transI18n("simulatedUniverse"), icon: 'SimulatedUniverse', value: 'SU' },
];
return (
<div className="min-h-screen">
{/* Header Navigation */}
<nav className="border-b border-warning/30 relative pb-2">
<div className="flex items-center justify-center">
{/* Mobile Select */}
<div className="block md:hidden w-full">
<select
className="select select-bordered w-full"
value={battle_type.toUpperCase()}
onChange={(e) => setBattleType(e.target.value.toUpperCase())}
>
{navItems.map((item) => (
<option key={item.name} value={item.value.toUpperCase()}>
{item.name}
</option>
))}
</select>
</div>
{/* Desktop Tabs */}
<div className="hidden md:grid grid-cols-3 lg:grid-cols-6 gap-1">
{navItems.map((item) => (
<button
key={item.name}
onClick={() => setBattleType(item.value.toUpperCase())}
className={`px-4 py-2 rounded-lg transition-all cursor-pointer duration-300 flex items-center space-x-2 ${battle_type.toUpperCase() === item.value.toUpperCase()
? 'bg-success/30 shadow-lg'
: 'bg-base-200/20 hover:bg-base-200/40 '
}`}
>
<span
style={
battle_type.toUpperCase() === item.value.toUpperCase()
? {
filter:
'brightness(0) saturate(100%) invert(63%) sepia(78%) saturate(643%) hue-rotate(1deg) brightness(93%) contrast(89%)',
}
: undefined
}
>
<Image
src={`/icon/${item.icon}.webp`}
alt={item.name}
width={24}
height={24}
/>
</span>
<span>{item.name}</span>
</button>
))}
</div>
</div>
</nav>
{(battle_type.toUpperCase() === "DEFAULT" || battle_type.toUpperCase() === "") && (
<div className="container mx-auto px-4 py-8 text-center font-bold text-3xl">
{transI18n("noEventSelected")}
</div>
)}
{battle_type.toUpperCase() === 'MOC' && <MocBar />}
{battle_type.toUpperCase() === 'PF' && <PfBar />}
{battle_type.toUpperCase() === 'AS' && <AsBar />}
{battle_type.toUpperCase() === 'CE' && <CeBar />}
{battle_type.toUpperCase() === 'PEAK' && <PeakBar />}
{battle_type.toUpperCase() === 'SU' && (
<div className="container mx-auto px-4 py-8 text-center font-bold text-3xl">
{transI18n("comingSoon")}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,330 @@
"use client"
import { useEffect, useMemo } from "react";
import SelectCustomText from "../select/customSelectText";
import useEventStore from "@/stores/eventStore";
import { getLocaleName, replaceByParam } from "@/helper";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";
import useMonsterStore from "@/stores/monsterStore";
import Image from "next/image";
import useMazeStore from "@/stores/mazeStore";
import { useTranslations } from "next-intl";
import { MonsterStore } from "@/types";
export default function MocBar() {
const { MOCEvent, mapMOCInfo } = useEventStore()
const { listMonster } = useMonsterStore()
const { locale } = useLocaleStore()
const {
moc_config,
setMocConfig
} = useUserDataStore()
const { MOC } = useMazeStore()
const transI18n = useTranslations("DataPage")
const challengeSelected = useMemo(() => {
return mapMOCInfo[moc_config.event_id.toString()]?.find((moc) => moc.Id === moc_config.challenge_id)
}, [moc_config, mapMOCInfo])
useEffect(() => {
const challenge = mapMOCInfo[moc_config.event_id.toString()]?.find((moc) => moc.Id === moc_config.challenge_id)
if (moc_config.event_id !== 0 && moc_config.challenge_id !== 0 && challenge) {
const newBattleConfig = structuredClone(moc_config)
newBattleConfig.cycle_count = 0
if (moc_config.use_cycle_count) {
newBattleConfig.cycle_count = challenge.Countdown
}
newBattleConfig.blessings = []
if (moc_config.use_turbulence_buff && MOC[moc_config.challenge_id.toString()]) {
newBattleConfig.blessings.push({
id: Number(MOC[moc_config.challenge_id.toString()].maze_buff),
level: 1
})
}
newBattleConfig.monsters = []
newBattleConfig.stage_id = 0
if ((moc_config.floor_side === "Upper" || moc_config.floor_side === "Upper -> Lower") && challenge.EventIDList1.length > 0) {
newBattleConfig.stage_id = challenge.EventIDList1[0].StageID
for (const wave of challenge.EventIDList1[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challenge.EventIDList1[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
if ((moc_config.floor_side === "Lower" || moc_config.floor_side === "Lower -> Upper") && challenge.EventIDList2.length > 0) {
newBattleConfig.stage_id = challenge.EventIDList2[0].StageID
for (const wave of challenge.EventIDList2[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challenge.EventIDList2[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
if (moc_config.floor_side === "Lower -> Upper" && challenge.EventIDList1.length > 0) {
for (const wave of challenge.EventIDList1[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challenge.EventIDList1[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
} else if (moc_config.floor_side === "Upper -> Lower" && challenge.EventIDList2.length > 0) {
for (const wave of challenge.EventIDList2[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challenge.EventIDList2[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
setMocConfig(newBattleConfig)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
moc_config.event_id,
moc_config.challenge_id,
moc_config.floor_side,
mapMOCInfo,
MOC,
moc_config.use_cycle_count,
moc_config.use_turbulence_buff,
])
if (!MOCEvent) return null
return (
<div className="py-8 relative">
{/* Title Card */}
<div className="rounded-xl p-4 mb-2 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={MOCEvent.map((moc) => ({
id: moc.id,
name: getLocaleName(locale, moc),
time: `${moc.begin} - ${moc.end}`,
}))}
excludeSet={[]}
selectedCustomSet={moc_config.event_id.toString()}
placeholder={transI18n("selectMOCEvent")}
setSelectedCustomSet={(id) => setMocConfig({
...moc_config,
event_id: Number(id),
challenge_id: mapMOCInfo[Number(id)]?.slice(-1)[0]?.Id || 0,
})}
/>
</div>
{/* Settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center gap-2">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("floor")}:{" "}</span>
</label>
<select
value={moc_config.challenge_id}
className="select select-success"
onChange={(e) => setMocConfig({
...moc_config,
challenge_id: Number(e.target.value)
})}
>
<option value={0} disabled={true}>Select a Floor</option>
{mapMOCInfo[moc_config.event_id.toString()]?.map((moc) => (
<option key={moc.Id} value={moc.Id}>{moc.Id % 100}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("side")}:{" "}</span>
</label>
<select
value={moc_config.floor_side}
className="select select-success"
onChange={(e) => setMocConfig({ ...moc_config, floor_side: e.target.value })}
>
<option value={0} disabled={true}>{transI18n("selectSide")}</option>
<option value="Upper">{transI18n("upper")}</option>
<option value="Lower">{transI18n("lower")}</option>
<option value="Upper -> Lower">{transI18n("upperToLower")}</option>
<option value="Lower -> Upper">{transI18n("lowerToUpper")}</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="label cursor-pointer">
<span
onClick={() => setMocConfig({ ...moc_config, use_cycle_count: !moc_config.use_cycle_count })}
className="label-text font-bold text-success cursor-pointer"
>
{transI18n("useCycleCount")} {" "}
</span>
<input
type="checkbox"
checked={moc_config.use_cycle_count}
onChange={(e) => setMocConfig({ ...moc_config, use_cycle_count: e.target.checked })}
className="checkbox checkbox-primary"
/>
</label>
</div>
</div>
{/* Turbulence Buff */}
<div className="bg-base-200/20 rounded-lg p-4 border border-purple-500/20">
<div className="flex items-center space-x-2 mb-2">
<input
type="checkbox"
checked={moc_config.use_turbulence_buff}
onChange={(e) => setMocConfig({ ...moc_config, use_turbulence_buff: e.target.checked })}
className="checkbox checkbox-primary"
/>
<span
onClick={() => setMocConfig({ ...moc_config, use_turbulence_buff: !moc_config.use_turbulence_buff })}
className="font-bold text-success cursor-pointer">
{transI18n("useTurbulenceBuff")}
</span>
</div>
{challengeSelected && challengeSelected?.Desc ? (
<div
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceByParam(
challengeSelected?.Desc || "",
challengeSelected?.Param || []
)
}}
/>
) : (
<div className="text-base">{transI18n("noTurbulenceBuff")}</div>
)}
</div>
</div>
{/* Enemy Waves */}
{(moc_config?.challenge_id ?? 0) !== 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* First Half */}
<div className="rounded-xl p-4 mt-2 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{transI18n("firstHalfEnemies")}</h2>
{challengeSelected && challengeSelected?.EventIDList1?.length > 0 && challengeSelected?.EventIDList1[0].MonsterList.map((wave, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold mb-t">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.values(wave).map((waveValue, enemyIndex) => (
<div
key={enemyIndex}
className="rounded-xl p-2 border border-white/10 shadow-md hover:border-white/20 hover:shadow-lg transition"
>
<div className="flex items-center space-x-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonster.find((monster) => monster.child.includes(waveValue))?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonster.find((monster) => monster.child.includes(waveValue))?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>}
</div>
<div className="flex flex-col">
<div className="text-sm font-semibold">Lv. {challengeSelected?.EventIDList1[0].Level}</div>
<div className="flex items-center space-x-1 mt-1">
{listMonster
.find((monster) => monster.child.includes(waveValue))
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Second Half */}
<div className="rounded-xl p-4 mt-2 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{transI18n("secondHalfEnemies")}</h2>
{challengeSelected && challengeSelected?.EventIDList2?.length > 0 && challengeSelected?.EventIDList2[0].MonsterList.map((wave, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold mb-t">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.values(wave).map((waveValue, enemyIndex) => (
<div
key={enemyIndex}
className="rounded-xl p-2 border border-white/10 shadow-md hover:border-white/20 hover:shadow-lg transition"
>
<div className="flex items-center space-x-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonster.find((monster) => monster.child.includes(waveValue))?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonster.find((monster) => monster.child.includes(waveValue))?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>}
</div>
<div className="flex flex-col">
<div className="text-sm font-semibold">Lv. {challengeSelected?.EventIDList1[0].Level}</div>
<div className="flex items-center space-x-1 mt-1">
{listMonster
.find((monster) => monster.child.includes(waveValue))
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,275 @@
"use client"
import { useEffect, useMemo } from "react";
import SelectCustomText from "../select/customSelectText";
import useEventStore from "@/stores/eventStore";
import { getLocaleName, replaceByParam } from "@/helper";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";
import useMonsterStore from "@/stores/monsterStore";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { MonsterStore } from "@/types";
export default function PeakBar() {
const { PEAKEvent, mapPEAKInfo } = useEventStore()
const { listMonster } = useMonsterStore()
const { locale } = useLocaleStore()
const {
peak_config,
setPeakConfig
} = useUserDataStore()
const transI18n = useTranslations("DataPage")
const listFloor = useMemo(() => {
if (!mapPEAKInfo?.[peak_config?.event_id?.toString()]) return []
return [
...mapPEAKInfo[peak_config?.event_id?.toString()]?.PreLevel,
mapPEAKInfo[peak_config?.event_id?.toString()]?.BossLevel,
]
}, [peak_config, mapPEAKInfo])
const eventSelected = useMemo(() => {
return mapPEAKInfo?.[peak_config?.event_id?.toString()]
}, [peak_config, mapPEAKInfo])
const bossConfig = useMemo(() => {
return mapPEAKInfo?.[peak_config?.event_id?.toString()]?.BossConfig;
}, [peak_config, mapPEAKInfo])
const challengeSelected = useMemo(() => {
const challenge = structuredClone(listFloor.find((peak) => peak.Id === peak_config.challenge_id))
if (
challenge
&& challenge.Id === mapPEAKInfo?.[peak_config?.event_id?.toString()]?.BossLevel?.Id
&& bossConfig
&& peak_config?.boss_mode === "Hard"
) {
challenge.Name = bossConfig.HardName
challenge.EventIDList = bossConfig.EventIDList
challenge.InfiniteList = bossConfig.InfiniteList
challenge.TagList = bossConfig.TagList
}
return challenge
}, [peak_config, listFloor, mapPEAKInfo, bossConfig])
useEffect(() => {
if (!challengeSelected) return
if (peak_config.event_id !== 0 && peak_config.challenge_id !== 0 && challengeSelected) {
const newBattleConfig = structuredClone(peak_config)
newBattleConfig.cycle_count = 6
newBattleConfig.blessings = []
for (const value of challengeSelected.TagList) {
newBattleConfig.blessings.push({
id: Number(value.Id),
level: 1
})
}
if (peak_config.buff_id !== 0) {
newBattleConfig.blessings.push({
id: peak_config.buff_id,
level: 1
})
}
newBattleConfig.monsters = []
newBattleConfig.stage_id = challengeSelected.EventIDList[0].StageID
for (const wave of challengeSelected.EventIDList[0].MonsterList) {
if (!wave) continue
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
if (!value) continue
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
setPeakConfig(newBattleConfig)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
peak_config.event_id,
peak_config.challenge_id,
peak_config.buff_id,
mapPEAKInfo,
])
if (!PEAKEvent) return null
return (
<div className="py-8 relative">
{/* Title Card */}
<div className="rounded-xl p-4 mb-2 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={PEAKEvent.map((peak) => ({
id: peak.id,
name: `${getLocaleName(locale, peak)} (${peak.id}) `,
}))}
excludeSet={[]}
selectedCustomSet={peak_config.event_id.toString()}
placeholder={transI18n("selectPEAKEvent")}
setSelectedCustomSet={(id) => setPeakConfig({ ...peak_config, event_id: Number(id), challenge_id: 0, buff_id: 0 })}
/>
</div>
{/* Settings */}
<div className={
`grid grid-cols-1
${eventSelected && eventSelected.BossLevel.Id === peak_config.challenge_id ? "md:grid-cols-2" : ""}
gap-4 mb-4 justify-items-center items-center w-full`}
>
<div className="flex items-center gap-2 w-full">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("floor")}:{" "}</span>
</label>
<select
value={peak_config.challenge_id}
className="select select-success w-full"
onChange={(e) => setPeakConfig({ ...peak_config, challenge_id: Number(e.target.value) })}
>
<option value={0} disabled={true}>{transI18n("selectFloor")}</option>
{listFloor.map((peak) => (
<option key={peak.Id} value={peak.Id}>{peak.Name}</option>
))}
</select>
</div>
{eventSelected && eventSelected.BossLevel.Id === peak_config.challenge_id && (
<div className="flex items-center gap-2 w-full">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("mode")}:{" "}</span>
</label>
<select
value={peak_config.boss_mode}
className="select select-success w-full"
onChange={(e) => setPeakConfig({ ...peak_config, boss_mode: e.target.value })}
>
<option value={0} disabled={true}>{transI18n("selectSide")}</option>
<option value="Normal">{transI18n("normalMode")}</option>
<option value="Hard">{transI18n("hardMode")}</option>
</select>
</div>
)}
</div>
{
eventSelected
&& eventSelected.BossLevel.Id === peak_config.challenge_id
&& bossConfig
&& bossConfig.BuffList
&& (
<div className="mb-4 w-full">
<SelectCustomText
customSet={
Array.isArray(bossConfig.BuffList)
? bossConfig.BuffList.map((buff) => ({
id: buff.Id.toString(),
name: buff?.Name || "",
description: replaceByParam(buff?.Desc || "", buff?.Param || []),
}))
: []
}
excludeSet={[]}
selectedCustomSet={peak_config?.buff_id?.toString()}
placeholder={transI18n("selectBuff")}
setSelectedCustomSet={(id) => setPeakConfig({ ...peak_config, buff_id: Number(id) })}
/>
</div>
)
}
{/* Turbulence Buff */}
<div className="bg-base-200/20 rounded-lg p-4 border border-purple-500/20">
<h2 className="text-2xl font-bold mb-2 text-info">
{transI18n("turbulenceBuff")}
</h2>
{challengeSelected && challengeSelected?.TagList?.length > 0 ? (
challengeSelected.TagList.map((subOption, index) => (
<div key={index}>
<label className="label">
<span className="label-text font-bold text-success">
{index + 1}. {subOption.Name}
</span>
</label>
<div
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceByParam(
subOption.Desc,
subOption.Param || []
)
}}
/>
</div>
))
) : (
<div className="text-base">{transI18n("noTurbulenceBuff")}</div>
)}
</div>
</div>
{/* Enemy Waves */}
{(peak_config?.challenge_id ?? 0) !== 0 && (
<div className="grid grid-cols-1 gap-4">
<div className="rounded-xl p-4 mt-2 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{challengeSelected?.Name}</h2>
{challengeSelected && Object.values(challengeSelected.InfiniteList).map((waveValue, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold mb-t">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from(new Set(waveValue.MonsterGroupIDList)).map((monsterId, enemyIndex) => (
<div
key={enemyIndex}
className="rounded-xl p-2 border border-white/10 shadow-md hover:border-white/20 hover:shadow-lg transition"
>
<div className="flex items-center space-x-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonster.find((monster) => monster.child.includes(monsterId))?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonster.find((monster) => monster.child.includes(monsterId))?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>}
</div>
<div className="flex flex-col">
<div className="text-sm font-semibold">Lv. {challengeSelected?.EventIDList[0].Level}</div>
<div className="flex items-center space-x-1 mt-1">
{listMonster
.find((monster) => monster.child.includes(monsterId))
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,339 @@
"use client"
import { useEffect, useMemo } from "react";
import SelectCustomText from "../select/customSelectText";
import useEventStore from "@/stores/eventStore";
import { getLocaleName, replaceByParam } from "@/helper";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";
import useMonsterStore from "@/stores/monsterStore";
import Image from "next/image";
import { MonsterStore } from "@/types";
import useMazeStore from "@/stores/mazeStore";
import { useTranslations } from "next-intl";
export default function PfBar() {
const { PFEvent, mapPFInfo } = useEventStore()
const { listMonster } = useMonsterStore()
const { locale } = useLocaleStore()
const {
pf_config,
setPfConfig
} = useUserDataStore()
const { PF } = useMazeStore()
const transI18n = useTranslations("DataPage")
const challengeSelected = useMemo(() => {
return mapPFInfo[pf_config.event_id.toString()]?.Level.find((pf) => pf.Id === pf_config.challenge_id)
}, [pf_config, mapPFInfo])
const eventSelected = useMemo(() => {
return mapPFInfo[pf_config.event_id.toString()]
}, [pf_config, mapPFInfo])
useEffect(() => {
if (!challengeSelected || pf_config.event_id === 0 || pf_config.challenge_id === 0) {
return
}
const newBattleConfig = structuredClone(pf_config)
newBattleConfig.cycle_count = 4
newBattleConfig.blessings = []
if (pf_config.buff_id !== 0) {
newBattleConfig.blessings.push({
id: pf_config.buff_id,
level: 1
})
}
if (PF[pf_config.challenge_id.toString()]) {
newBattleConfig.blessings.push({
id: Number(PF[pf_config.challenge_id.toString()].maze_buff),
level: 1
})
}
newBattleConfig.monsters = []
newBattleConfig.stage_id = 0
if ((pf_config.floor_side === "Upper" || pf_config.floor_side === "Upper -> Lower") && challengeSelected.EventIDList1.length > 0) {
newBattleConfig.stage_id = challengeSelected.EventIDList1[0].StageID
for (const wave of challengeSelected.EventIDList1[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList1[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
if ((pf_config.floor_side === "Lower" || pf_config.floor_side === "Lower -> Upper") && challengeSelected.EventIDList2.length > 0) {
newBattleConfig.stage_id = challengeSelected.EventIDList2[0].StageID
for (const wave of challengeSelected.EventIDList2[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList2[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
if (pf_config.floor_side === "Lower -> Upper" && challengeSelected.EventIDList1.length > 0) {
for (const wave of challengeSelected.EventIDList1[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList1[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
} else if (pf_config.floor_side === "Upper -> Lower" && challengeSelected.EventIDList2.length > 0) {
for (const wave of challengeSelected.EventIDList2[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
newWave.push({
monster_id: Number(value),
level: challengeSelected.EventIDList2[0].Level,
amount: 1,
})
}
newBattleConfig.monsters.push(newWave)
}
}
setPfConfig(newBattleConfig)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
challengeSelected,
pf_config.event_id,
pf_config.challenge_id,
pf_config.floor_side,
pf_config.buff_id,
mapPFInfo,
PF,
])
if (!PFEvent) return null
return (
<div className="py-8 relative">
{/* Title Card */}
<div className="rounded-xl p-4 mb-2 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={PFEvent.map((pf) => ({
id: pf.id,
name: getLocaleName(locale, pf),
time: `${pf.begin} - ${pf.end}`,
}))}
excludeSet={[]}
selectedCustomSet={pf_config.event_id.toString()}
placeholder={transI18n("selectPFEvent")}
setSelectedCustomSet={(id) => setPfConfig({
...pf_config,
event_id: Number(id),
challenge_id: mapPFInfo[Number(id)]?.Level.slice(-1)[0]?.Id || 0,
buff_id: 0
})}
/>
</div>
{/* Settings */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center gap-2">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("floor")}:{" "}</span>
</label>
<select
value={pf_config.challenge_id}
className="select select-success"
onChange={(e) => setPfConfig({ ...pf_config, challenge_id: Number(e.target.value) })}
>
<option value={0} disabled={true}>{transI18n("selectFloor")}</option>
{mapPFInfo[pf_config.event_id.toString()]?.Level.map((pf) => (
<option key={pf.Id} value={pf.Id}>{pf.Id % 10}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="label">
<span className="label-text font-bold text-success">{transI18n("side")}:{" "}</span>
</label>
<select
value={pf_config.floor_side}
className="select select-success"
onChange={(e) => setPfConfig({ ...pf_config, floor_side: e.target.value })}
>
<option value={0} disabled={true}>{transI18n("selectSide")}</option>
<option value="Upper">{transI18n("upper")}</option>
<option value="Lower">{transI18n("lower")}</option>
<option value="Upper -> Lower">{transI18n("upperToLower")}</option>
<option value="Lower -> Upper">{transI18n("lowerToUpper")}</option>
</select>
</div>
</div>
{eventSelected && (
<div className="mb-4 w-full">
<SelectCustomText
customSet={eventSelected.Option.map((buff, index) => ({
id: PF[eventSelected.Id.toString()]?.buff[index]?.toString(),
name: buff.Name,
description: replaceByParam(buff.Desc, buff.Param || []),
}))}
excludeSet={[]}
selectedCustomSet={pf_config?.buff_id?.toString()}
placeholder={transI18n("selectBuff")}
setSelectedCustomSet={(id) => setPfConfig({ ...pf_config, buff_id: Number(id) })}
/>
</div>
)}
{/* Turbulence Buff */}
<div className="bg-base-200/20 rounded-lg p-4 border border-purple-500/20">
<h2 className="text-2xl font-bold mb-2 text-info">{transI18n("turbulenceBuff")}</h2>
{eventSelected && eventSelected.SubOption.length > 0 ? (
eventSelected.SubOption.map((subOption, index) => (
<div key={index}>
<label className="label">
<span className="label-text font-bold text-success">{index + 1}. {subOption.Name}</span>
</label>
<div
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceByParam(
subOption.Desc,
subOption.Param || []
)
}}
/>
</div>
))
) : eventSelected && eventSelected.SubOption.length === 0 ? (
<div
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceByParam(
eventSelected.Buff?.Desc || "",
eventSelected.Buff?.Param || []
)
}}
/>
) : (
<div className="text-base">{transI18n("noTurbulenceBuff")}</div>
)}
</div>
</div>
{/* Enemy Waves */}
{(pf_config?.challenge_id ?? 0) !== 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* First Half */}
<div className="rounded-xl p-4 mt-2 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{transI18n("firstHalfEnemies")}</h2>
{challengeSelected && Object.values(challengeSelected.InfiniteList1).map((waveValue, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold mb-t">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from(new Set(waveValue.MonsterGroupIDList)).map((monsterId, enemyIndex) => (
<div
key={enemyIndex}
className="rounded-xl p-2 border border-white/10 shadow-md hover:border-white/20 hover:shadow-lg transition"
>
<div className="flex items-center space-x-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonster.find((monster) => monster.child.includes(monsterId))?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonster.find((monster) => monster.child.includes(monsterId))?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>}
</div>
<div className="flex flex-col">
<div className="text-sm font-semibold">Lv. {challengeSelected?.EventIDList1[0].Level}</div>
<div className="flex items-center space-x-1 mt-1">
{listMonster
.find((monster) => monster.child.includes(monsterId))
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Second Half */}
<div className="rounded-xl p-4 mt-2 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{transI18n("secondHalfEnemies")}</h2>
{challengeSelected && Object.values(challengeSelected?.InfiniteList2).map((waveValue, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold mb-t">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from(new Set(waveValue.MonsterGroupIDList)).map((monsterId, enemyIndex) => (
<div
key={enemyIndex}
className="rounded-xl p-2 border border-white/10 shadow-md hover:border-white/20 hover:shadow-lg transition"
>
<div className="flex items-center space-x-3">
<div className="relative w-20 h-20 rounded-full overflow-hidden flex-shrink-0 border border-white/10 shadow-sm">
{listMonster.find((monster) => monster.child.includes(monsterId))?.icon && <Image
src={`https://api.hakush.in/hsr/UI/monstermiddleicon/${listMonster.find((monster) => monster.child.includes(monsterId))?.icon?.split("/")?.pop()?.replace(".png", "")}.webp`}
alt="Enemy Icon"
width={376}
height={512}
className="w-full h-full object-cover"
/>}
</div>
<div className="flex flex-col">
<div className="text-sm font-semibold">Lv. {challengeSelected?.EventIDList1[0].Level}</div>
<div className="flex items-center space-x-1 mt-1">
{listMonster
.find((monster) => monster.child.includes(monsterId))
?.weak?.map((icon, iconIndex) => (
<Image
src={`/icon/${icon.toLowerCase()}.webp`}
alt={icon}
className="h-[28px] w-[28px] 2xl:h-[40px] 2xl:w-[40px] object-contain rounded-md border border-white/20 shadow-sm"
width={200}
height={200}
key={iconIndex}
/>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,16 @@
'use client'
import { parseRuby } from "@/helper";
interface TextProps {
text: string;
locale: string;
className?: string;
}
export default function ParseText({ text, locale, className }: TextProps) {
if (locale === "ja") {
return <div className={className} dangerouslySetInnerHTML={{ __html: parseRuby(text) }} />;
}
return <div className={className}>{text}</div>;
}

View File

@@ -0,0 +1,12 @@
"use client";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react';
export default function QueryProviderWrapper({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -0,0 +1,462 @@
"use client"
import NextImage from "next/image"
import useLightconeStore from "@/stores/lightconeStore";
import useAffixStore from "@/stores/affixStore";
import useUserDataStore from "@/stores/userDataStore";
import useRelicStore from "@/stores/relicStore";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
import useAvatarStore from "@/stores/avatarStore";
import { calcAffixBonus, calcBaseStatRaw, calcBonusStatRaw, calcMainAffixBonus, calcMainAffixBonusRaw, calcPromotion, calcSubAffixBonusRaw, replaceByParam } from "@/helper";
import { mappingStats } from "@/constant/constant";
import RelicShowcase from "../showcaseCard/relicShowcase";
export default function QuickView() {
const { avatarSelected, mapAvatarInfo } = useAvatarStore()
const { mapLightconeInfo } = useLightconeStore()
const { mapMainAffix, mapSubAffix } = useAffixStore()
const { avatars } = useUserDataStore()
const transI18n = useTranslations("DataPage")
const { mapRelicInfo } = useRelicStore()
const avatarInfo = useMemo(() => {
if (!avatarSelected) return
return mapAvatarInfo[avatarSelected.id]
}, [avatarSelected, mapAvatarInfo])
const avatarSkillTree = useMemo(() => {
if (!avatarSelected || !avatars[avatarSelected.id]) return {}
if (avatars[avatarSelected.id].enhanced) {
return avatarInfo?.Enhanced[avatars[avatarSelected.id].enhanced.toString()].SkillTrees || {}
}
return avatarInfo?.SkillTrees || {}
}, [avatarSelected, avatarInfo, avatars])
const avatarData = useMemo(() => {
if (!avatarSelected) return
return avatars[avatarSelected.id]
}, [avatarSelected, avatars])
const avatarProfile = useMemo(() => {
if (!avatarSelected || !avatarData) return
return avatarData?.profileList?.[avatarData?.profileSelect]
}, [avatarSelected, avatarData])
const relicEffects = useMemo(() => {
const avatar = avatars[avatarSelected?.id || ""];
const relicCount: { [key: string]: number } = {};
if (avatar) {
for (const relic of Object.values(avatar.profileList[avatar.profileSelect].relics)) {
if (relicCount[relic.relic_set_id]) {
relicCount[relic.relic_set_id]++;
} else {
relicCount[relic.relic_set_id] = 1;
}
}
}
const listEffects: { key: string, count: number }[] = [];
Object.entries(relicCount).forEach(([key, value]) => {
if (value >= 2) {
listEffects.push({ key: key, count: value });
}
});
return listEffects;
}, [avatars, avatarSelected]);
const relicStats = useMemo(() => {
if (!avatarSelected || !avatarProfile?.relics || !mapMainAffix || !mapSubAffix) return
return Object.entries(avatarProfile?.relics).map(([key, value]) => {
const mainAffixMap = mapMainAffix["5" + key]
const subAffixMap = mapSubAffix["5"]
if (!mainAffixMap || !subAffixMap) return
return {
img: `https://api.hakush.in/hsr/UI/relicfigures/IconRelic_${value.relic_set_id}_${key}.webp`,
mainAffix: {
property: mainAffixMap?.[value?.main_affix_id]?.property,
level: value?.level,
valueAffix: calcMainAffixBonus(mainAffixMap?.[value?.main_affix_id], value?.level),
detail: mappingStats?.[mainAffixMap?.[value?.main_affix_id]?.property]
},
subAffix: value?.sub_affixes?.map((subValue) => {
return {
property: subAffixMap?.[subValue?.sub_affix_id]?.property,
valueAffix: calcAffixBonus(subAffixMap?.[subValue?.sub_affix_id], subValue?.step, subValue?.count),
detail: mappingStats?.[subAffixMap?.[subValue?.sub_affix_id]?.property],
step: subValue?.step,
count: subValue?.count
}
})
}
})
}, [avatarSelected, avatarProfile, mapMainAffix, mapSubAffix])
const characterStats = useMemo(() => {
if (!avatarSelected || !avatarData) return
const charPromotion = calcPromotion(avatarData.level)
const statsData: Record<string, {
value: number,
base: number,
name: string,
icon: string,
unit: string,
round: number
}> = {
HP: {
value: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPAdd,
avatarData.level
),
base: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPAdd,
avatarData.level
),
name: "HP",
icon: "/icon/hp.webp",
unit: "",
round: 0
},
ATK: {
value: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackAdd,
avatarData.level
),
base: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackAdd,
avatarData.level
),
name: "ATK",
icon: "/icon/attack.webp",
unit: "",
round: 0
},
DEF: {
value: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceAdd,
avatarData.level
),
base: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceAdd,
avatarData.level
),
name: "DEF",
icon: "/icon/defence.webp",
unit: "",
round: 0
},
SPD: {
value: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.SpeedBase || 0,
base: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.SpeedBase || 0,
name: "SPD",
icon: "/icon/speed.webp",
unit: "",
round: 1
},
CRITRate: {
value: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalChance || 0,
base: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalChance || 0,
name: "CRIT Rate",
icon: "/icon/crit-rate.webp",
unit: "%",
round: 1
},
CRITDmg: {
value: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalDamage || 0,
base: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalDamage || 0,
name: "CRIT DMG",
icon: "/icon/crit-damage.webp",
unit: "%",
round: 1
},
BreakEffect: {
value: 0,
base: 0,
name: "Break Effect",
icon: "/icon/break-effect.webp",
unit: "%",
round: 1
},
EffectRES: {
value: 0,
base: 0,
name: "Effect RES",
icon: "/icon/effect-res.webp",
unit: "%",
round: 1
},
EnergyRate: {
value: 0,
base: 0,
name: "Energy Rate",
icon: "/icon/energy-rate.webp",
unit: "%",
round: 1
},
EffectHitRate: {
value: 0,
base: 0,
name: "Effect Hit Rate",
icon: "/icon/effect-hit-rate.webp",
unit: "%",
round: 1
},
HealBoost: {
value: 0,
base: 0,
name: "Healing Boost",
icon: "/icon/healing-boost.webp",
unit: "%",
round: 1
},
PhysicalAdd: {
value: 0,
base: 0,
name: "Physical Boost",
icon: "/icon/physical-add.webp",
unit: "%",
round: 1
},
FireAdd: {
value: 0,
base: 0,
name: "Fire Boost",
icon: "/icon/fire-add.webp",
unit: "%",
round: 1
},
IceAdd: {
value: 0,
base: 0,
name: "Ice Boost",
icon: "/icon/ice-add.webp",
unit: "%",
round: 1
},
ThunderAdd: {
value: 0,
base: 0,
name: "Thunder Boost",
icon: "/icon/thunder-add.webp",
unit: "%",
round: 1
},
WindAdd: {
value: 0,
base: 0,
name: "Wind Boost",
icon: "/icon/wind-add.webp",
unit: "%",
round: 1
},
QuantumAdd: {
value: 0,
base: 0,
name: "Quantum Boost",
icon: "/icon/quantum-add.webp",
unit: "%",
round: 1
},
ImaginaryAdd: {
value: 0,
base: 0,
name: "Imaginary Boost",
icon: "/icon/imaginary-add.webp",
unit: "%",
round: 1
},
}
if (avatarProfile?.lightcone && mapLightconeInfo[avatarProfile?.lightcone?.item_id]) {
const lightconePromotion = calcPromotion(avatarProfile?.lightcone?.level)
statsData.HP.value += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHP,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHPAdd,
avatarProfile?.lightcone?.level
)
statsData.HP.base += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHP,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHPAdd,
avatarProfile?.lightcone?.level
)
statsData.ATK.value += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttack,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttackAdd,
avatarProfile?.lightcone?.level
)
statsData.ATK.base += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttack,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttackAdd,
avatarProfile?.lightcone?.level
)
statsData.DEF.value += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefence,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefenceAdd,
avatarProfile?.lightcone?.level
)
statsData.DEF.base += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefence,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefenceAdd,
avatarProfile?.lightcone?.level
)
const bonusData = mapLightconeInfo[avatarProfile?.lightcone?.item_id].Bonus?.[avatarProfile?.lightcone.rank - 1]
if (bonusData && bonusData.length > 0) {
const bonusSpd = bonusData.filter((bonus) => bonus.type === "BaseSpeed")
const bonusOther = bonusData.filter((bonus) => bonus.type !== "BaseSpeed")
bonusSpd.forEach((bonus) => {
statsData.SPD.value += bonus.value
statsData.SPD.base += bonus.value
})
bonusOther.forEach((bonus) => {
const statsBase = mappingStats?.[bonus.type]?.baseStat
if (statsBase && statsData[statsBase]) {
statsData[statsBase].value += calcBonusStatRaw(bonus.type, statsData[statsBase].base, bonus.value)
}
})
}
}
if (avatarSkillTree) {
Object.values(avatarSkillTree).forEach((value) => {
if (value?.["1"]
&& value?.["1"]?.PointID
&& typeof avatarData?.data?.skills?.[value?.["1"]?.PointID] === "number"
&& avatarData?.data?.skills?.[value?.["1"]?.PointID] !== 0
&& value?.["1"]?.StatusAddList
&& value?.["1"].StatusAddList.length > 0) {
value?.["1"]?.StatusAddList.forEach((status) => {
const statsBase = mappingStats?.[status?.PropertyType]?.baseStat
if (statsBase && statsData[statsBase]) {
statsData[statsBase].value += calcBonusStatRaw(status?.PropertyType, statsData[statsBase].base, status.Value)
}
})
}
})
}
if (avatarProfile?.relics && mapMainAffix && mapSubAffix) {
Object.entries(avatarProfile?.relics).forEach(([key, value]) => {
const mainAffixMap = mapMainAffix["5" + key]
const subAffixMap = mapSubAffix["5"]
if (!mainAffixMap || !subAffixMap) return
const mainStats = mappingStats?.[mainAffixMap?.[value.main_affix_id]?.property]?.baseStat
if (mainStats && statsData[mainStats]) {
statsData[mainStats].value += calcMainAffixBonusRaw(mainAffixMap?.[value.main_affix_id], value.level, statsData[mainStats].base)
}
value?.sub_affixes.forEach((subValue) => {
const subStats = mappingStats?.[subAffixMap?.[subValue.sub_affix_id]?.property]?.baseStat
if (subStats && statsData[subStats]) {
statsData[subStats].value += calcSubAffixBonusRaw(subAffixMap?.[subValue.sub_affix_id], subValue.step, subValue.count, statsData[subStats].base)
}
})
})
}
if (relicEffects && relicEffects.length > 0) {
relicEffects.forEach((relic) => {
const dataBonus = mapRelicInfo?.[relic.key]?.Bonus
if (!dataBonus || Object.keys(dataBonus).length === 0) return
Object.entries(dataBonus || {}).forEach(([key, value]) => {
if (relic.count < Number(key)) return
value.forEach((bonus) => {
const statsBase = mappingStats?.[bonus.type]?.baseStat
if (statsBase && statsData[statsBase]) {
statsData[statsBase].value += calcBonusStatRaw(bonus.type, statsData[statsBase].base, bonus.value)
}
})
})
})
}
return statsData
}, [
avatarSelected,
avatarData,
mapAvatarInfo,
avatarProfile?.lightcone,
avatarProfile?.relics,
mapLightconeInfo,
mapMainAffix,
mapSubAffix,
relicEffects,
mapRelicInfo,
avatarSkillTree
])
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="col-span-1 md:col-span-2 flex flex-col justify-between py-3">
<div className="flex w-full flex-col justify-between gap-y-0.5 text-base">
{Object.entries(characterStats || {})?.map(([key, stat], index) => {
if (!stat || (key.includes("Add") && stat.value === 0)) return null
return (
<div key={index} className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<NextImage src={stat?.icon || ""} alt="Stat Icon" width={40} height={40} className="h-auto w-10 p-1 mx-1 bg-black/20 rounded-full" />
<div className="font-bold">{stat.name}</div>
</div>
<div className="ml-3 mr-3 flex-grow border rounded opacity-50" />
<div className="flex cursor-default flex-col text-right font-bold">{
stat.value ? stat.unit === "%" ? (stat.value * 100).toFixed(stat.round) : stat.value.toFixed(stat.round) : 0
}{stat.unit}</div>
</div>
)
})}
<hr />
</div>
<div className="flex flex-col items-center gap-1 w-full my-2">
{relicEffects.map((setEffect, index) => {
const relicInfo = mapRelicInfo[setEffect.key];
if (!relicInfo) return null;
return (
<div key={index} className="flex w-full flex-row justify-between text-left">
<div
className="font-bold truncate max-w-full mr-1"
style={{
fontSize: 'clamp(0.5rem, 2vw, 1rem)'
}}
dangerouslySetInnerHTML={{
__html: replaceByParam(
relicInfo.Name,
[]
)
}}
/>
<div>
<span className="black-blur bg-black/20 flex w-5 justify-center rounded px-1.5 py-0.5">{setEffect.count}</span>
</div>
</div>
)
})}
</div>
</div>
<div className="grid grid-cols-1 gap-2 justify-between py-3 text-lg">
{relicStats?.map((relic, index) => {
if (!relic || !avatarInfo) return null
return (
<RelicShowcase key={index} relic={relic} avatarInfo={avatarInfo} />
)
})}
{(!relicStats || !relicStats?.length) && (
<div className="flex flex-col items-center justify-center">
<div className="text-center p-6 rounded-lg bg-black/40 backdrop-blur-sm border border-white/10">
<span className="text-lg text-gray-400">{transI18n("noRelicEquipped")}</span>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,497 @@
"use client";
import useRelicStore from '@/stores/relicStore';
import useUserDataStore from '@/stores/userDataStore';
import { AffixDetail, RelicDetail } from '@/types';
import React, { useEffect, useMemo, useState } from 'react';
import SelectCustomImage from '../select/customSelectImage';
import { calcAffixBonus, calcMainAffixBonus, randomPartition, randomStep, replaceByParam } from '@/helper';
import useAffixStore from '@/stores/affixStore';
import { mappingStats } from '@/constant/constant';
import useAvatarStore from '@/stores/avatarStore';
import useModelStore from '@/stores/modelStore';
import useRelicMakerStore from '@/stores/relicMakerStore';
import { toast } from 'react-toastify';
import { useTranslations } from 'next-intl'
import { ChevronDown, ChevronUp } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
export default function RelicMaker() {
const { avatars, setAvatars } = useUserDataStore()
const { avatarSelected } = useAvatarStore()
const { setIsOpenRelic } = useModelStore()
const { mapRelicInfo } = useRelicStore()
const { mapMainAffix, mapSubAffix } = useAffixStore()
const transI18n = useTranslations("DataPage")
const {
selectedRelicSlot,
selectedRelicSet,
selectedMainStat,
listSelectedSubStats,
selectedRelicLevel,
preSelectedSubStats,
setSelectedRelicSet,
setSelectedMainStat,
setSelectedRelicLevel,
setListSelectedSubStats,
resetHistory,
popHistory,
addHistory,
} = useRelicMakerStore()
const [error, setError] = useState<string>("");
const relicSets = useMemo(() => {
const listSet: Record<string, RelicDetail> = {};
for (const [key, value] of Object.entries(mapRelicInfo || {})) {
let isOk = false;
for (const key2 of Object.keys(value.Parts)) {
if (key2.endsWith(selectedRelicSlot)) {
isOk = true;
break;
}
}
if (isOk) {
listSet[key] = value;
}
}
return listSet;
}, [mapRelicInfo, selectedRelicSlot]);
const subAffixOptions = useMemo(() => {
const listSet: Record<string, AffixDetail> = {};
const subAffixMap = mapSubAffix["5"];
const mainAffixMap = mapMainAffix["5" + selectedRelicSlot]
if (Object.keys(subAffixMap || {}).length === 0 || Object.keys(mainAffixMap || {}).length === 0) return listSet;
for (const [key, value] of Object.entries(subAffixMap)) {
if (value.property !== mainAffixMap[selectedMainStat]?.property) {
listSet[key] = value;
}
}
return listSet;
}, [mapSubAffix, mapMainAffix, selectedRelicSlot, selectedMainStat]);
useEffect(() => {
const subAffixMap = mapSubAffix["5"];
const mainAffixMap = mapMainAffix["5" + selectedRelicSlot];
if (!subAffixMap || !mainAffixMap) return;
const mainProp = mainAffixMap[selectedMainStat]?.property;
if (!mainProp) return;
const newSubAffixes = structuredClone(listSelectedSubStats);
let updated = false;
for (let i = 0; i < newSubAffixes.length; i++) {
if (newSubAffixes[i].property === mainProp) {
newSubAffixes[i].affixId = "";
newSubAffixes[i].property = "";
newSubAffixes[i].rollCount = 0;
newSubAffixes[i].stepCount = 0;
updated = true;
}
}
if (updated) setListSelectedSubStats(newSubAffixes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMainStat, mapSubAffix, mapMainAffix, selectedRelicSlot]);
const exSubAffixOptions = useMemo(() => {
const listSet: Record<string, AffixDetail> = {};
const subAffixMap = mapSubAffix["5"];
const mainAffixMap = mapMainAffix["5" + selectedRelicSlot];
if (!subAffixMap || !mainAffixMap) return listSet;
for (const [key, value] of Object.entries(subAffixMap)) {
const subAffix = listSelectedSubStats.find((item) => item.property === value.property);
if (subAffix && value.property !== mainAffixMap[selectedMainStat]?.property) {
listSet[key] = value;
}
}
return listSet;
}, [mapSubAffix, listSelectedSubStats, mapMainAffix, selectedRelicSlot, selectedMainStat]);
const effectBonus = useMemo(() => {
const affixSet = mapMainAffix?.["5" + selectedRelicSlot];
if (!affixSet) return 0;
const data = affixSet[selectedMainStat];
if (!data) return 0;
return calcMainAffixBonus(data, selectedRelicLevel);
}, [mapMainAffix, selectedRelicSlot, selectedMainStat, selectedRelicLevel]);
const handleSubStatChange = (key: string, index: number, rollCount: number, stepCount: number) => {
setError("");
const newSubAffixes = structuredClone(listSelectedSubStats);
if (!subAffixOptions[key]) {
newSubAffixes[index].affixId = "";
newSubAffixes[index].property = "";
newSubAffixes[index].rollCount = rollCount;
newSubAffixes[index].stepCount = stepCount;
setListSelectedSubStats(newSubAffixes);
addHistory(index, newSubAffixes[index]);
return;
}
newSubAffixes[index].affixId = key;
newSubAffixes[index].property = subAffixOptions[key].property;
newSubAffixes[index].rollCount = rollCount;
newSubAffixes[index].stepCount = stepCount;
setListSelectedSubStats(newSubAffixes);
addHistory(index, newSubAffixes[index]);
};
const handlerRollback = (index: number) => {
setError("");
if (!preSelectedSubStats[index]) return;
const keys = Object.keys(preSelectedSubStats[index]);
if (keys.length <= 1) return;
const newSubAffixes = structuredClone(listSelectedSubStats);
const listHistory = structuredClone(preSelectedSubStats[index]);
const secondLastKey = listHistory.length - 2;
const preSubAffixes = { ...listHistory[secondLastKey] };
newSubAffixes[index].rollCount = preSubAffixes.rollCount;
newSubAffixes[index].stepCount = preSubAffixes.stepCount;
setListSelectedSubStats(newSubAffixes);
popHistory(index);
};
const resetSubStat = (index: number) => {
const newSubAffixes = structuredClone(listSelectedSubStats);
resetHistory(index);
newSubAffixes[index].affixId = "";
newSubAffixes[index].property = "";
newSubAffixes[index].rollCount = 0;
newSubAffixes[index].stepCount = 0;
setListSelectedSubStats(newSubAffixes);
};
const randomizeStats = () => {
const newSubAffixes = structuredClone(listSelectedSubStats);
const exKeys = Object.keys(exSubAffixOptions);
for (let i = 0; i < newSubAffixes.length; i++) {
const keys = Object.keys(subAffixOptions).filter((key) => !exKeys.includes(key));
const randomKey = keys[Math.floor(Math.random() * keys.length)];
exKeys.push(randomKey);
const randomValue = subAffixOptions[randomKey];
newSubAffixes[i].affixId = randomKey;
newSubAffixes[i].property = randomValue.property;
newSubAffixes[i].rollCount = 0;
newSubAffixes[i].stepCount = 0;
}
for (let i = 0; i < newSubAffixes.length; i++) {
addHistory(i, newSubAffixes[i]);
}
setListSelectedSubStats(newSubAffixes);
};
const randomizeRolls = () => {
const newSubAffixes = structuredClone(listSelectedSubStats);
const randomRolls = randomPartition(9, listSelectedSubStats.length);
for (let i = 0; i < listSelectedSubStats.length; i++) {
newSubAffixes[i].rollCount = randomRolls[i];
newSubAffixes[i].stepCount = randomStep(randomRolls[i]);
}
setListSelectedSubStats(newSubAffixes);
for (let i = 0; i < newSubAffixes.length; i++) {
addHistory(i, newSubAffixes[i]);
}
};
const handlerSaveRelic = () => {
setError("");
const avatar = avatars[avatarSelected?.id || ""];
if (!selectedRelicSet || !selectedMainStat || !selectedRelicLevel || !selectedRelicSlot) {
setError(transI18n("pleaseSelectAllOptions"));
return;
};
if (listSelectedSubStats.find((item) => item.affixId === "")) {
setError(transI18n("pleaseSelectAllSubStats"));
return;
};
if (avatar) {
avatar.profileList[avatar.profileSelect].relics[selectedRelicSlot] = {
level: selectedRelicLevel,
relic_id: Number(`6${selectedRelicSet}${selectedRelicSlot}`),
relic_set_id: Number(selectedRelicSet),
main_affix_id: Number(selectedMainStat),
sub_affixes: listSelectedSubStats.map((item) => {
return {
sub_affix_id: Number(item.affixId),
count: item.rollCount,
step: item.stepCount
}
})
}
}
setAvatars({ ...avatars });
setIsOpenRelic(false);
toast.success(transI18n("relicSavedSuccessfully"));
}
return (
<div className="">
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-400">
{transI18n("relicMaker")}
</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 max-w-7xl mx-auto">
{/* Left Panel */}
<div className="space-y-6">
{/* Set Configuration */}
<div className="bg-base-100 rounded-xl p-6 border border-slate-700">
<h2 className="text-xl font-bold mb-6 text-warning">{transI18n("mainSettings")}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{/* Main Stat */}
<div>
<label className="block text-lg font-medium mb-2">{transI18n("mainStat")}</label>
<SelectCustomImage
customSet={Object.entries(mapMainAffix["5" + selectedRelicSlot] || {}).map(([key, value]) => ({
value: key,
label: mappingStats[value.property].name + " " + mappingStats[value.property].unit,
imageUrl: mappingStats[value.property].icon
}))}
excludeSet={[]}
selectedCustomSet={selectedMainStat}
placeholder={transI18n("selectAMainStat")}
setSelectedCustomSet={setSelectedMainStat}
/>
</div>
{/* Relic Set Selection */}
<div>
<label className="block text-lg font-medium mb-2">{transI18n("set")}</label>
<SelectCustomImage
customSet={Object.entries(relicSets).map(([key, value]) => ({
value: key,
label: value.Name,
imageUrl: `https://api.hakush.in/hsr/UI/itemfigures/${value.Icon.match(/\d+/)?.[0]}.webp`
}))}
excludeSet={[]}
selectedCustomSet={selectedRelicSet}
placeholder={transI18n("selectASet")}
setSelectedCustomSet={setSelectedRelicSet}
/>
</div>
</div>
{/* Set Bonus Display */}
<div className="mb-6 py-4 bg-base-100 rounded-lg">
{selectedRelicSet !== "" ? Object.entries(mapRelicInfo[selectedRelicSet].RequireNum).map(([key, value]) => (
<div key={key} className="text-blue-300 text-sm mb-1">
<span className="text-info font-bold">{key}-Pc:
<div
className="text-warning leading-relaxed font-bold"
dangerouslySetInnerHTML={{
__html: replaceByParam(
value.Desc,
value.ParamList || []
)
}}
/> </span>
</div>
)) : <p className="text-blue-300 text-sm font-bold mb-1">{transI18n("pleaseSelectASet")}</p>}
</div>
{/* Rarity */}
<div className="grid grid-cols-2 items-center gap-4 mb-6">
<label className="block text-lg font-medium mb-2">{transI18n("rarity")}: {5} </label>
<label className="block text-lg font-medium mb-2">{transI18n("effectBonus")}: <span className="text-warning font-bold">{effectBonus}</span></label>
</div>
{/* Level */}
<div className="mb-6">
<label className="block text-lg font-medium mb-2">{transI18n("level")}</label>
<div className="bg-base-200 rounded-lg p-4">
<input
type="range"
min="0"
max="15"
value={selectedRelicLevel}
onChange={(e) => setSelectedRelicLevel(parseInt(e.target.value))}
className="range range-primary w-full"
/>
<div className="text-center text-2xl font-bold mt-2">{selectedRelicLevel}</div>
</div>
</div>
<AnimatePresence>
{error && (
<motion.p
key="error"
initial={{ x: 0 }}
animate={{ x: [0, -2, 2, -2, 2, 0] }}
transition={{
duration: 6,
repeat: Infinity,
ease: "easeInOut",
repeatDelay: 1.5,
}}
className="text-error my-2 text-center font-bold"
>
{error}!
</motion.p>
)}
</AnimatePresence>
{/* Save Button */}
<button onClick={handlerSaveRelic} className="btn btn-success w-full">
{transI18n("save")}
</button>
</div>
</div>
{/* Right Panel - Sub Stats */}
<div className="space-y-4">
{/* Total Roll */}
<div className="bg-base-100 rounded-xl p-4 border border-slate-700 z-[1]">
<h3 className="text-lg font-bold mb-4">{transI18n("totalRoll")} {listSelectedSubStats.reduce((a, b) => a + b.rollCount, 0)}</h3>
<div className="grid grid-cols-2 gap-2">
<button
className="btn btn-outline btn-success sm:btn-sm"
onClick={randomizeStats}
>
{transI18n("randomizeStats")}
</button>
<button
className="btn btn-outline btn-success sm:btn-sm"
onClick={randomizeRolls}
>
{transI18n("randomizeRolls")}
</button>
</div>
</div>
{listSelectedSubStats.map((v, index) => (
<div key={index} className={`bg-base-100 rounded-xl p-4 border border-slate-700`}>
<div className="grid grid-cols-12 gap-2 items-center">
{/* Stat Selection */}
<div className="col-span-8">
<SelectCustomImage
customSet={Object.entries(subAffixOptions).map(([key, value]) => ({
value: key,
label: mappingStats[value.property].name + " " + mappingStats[value.property].unit,
imageUrl: mappingStats[value.property].icon
}))}
excludeSet={Object.entries(exSubAffixOptions).map(([key, value]) => ({
value: key,
label: mappingStats[value.property].name + " " + mappingStats[value.property].unit,
imageUrl: mappingStats[value.property].icon
}))}
selectedCustomSet={v.affixId}
placeholder={transI18n("selectASubStat")}
setSelectedCustomSet={(key) => handleSubStatChange(key, index, 0, 0)}
/>
</div>
{/* Current Value */}
<div className="col-span-4 text-center flex items-center justify-center gap-2">
<span className="text-2xl font-mono">+{ }</span>
<div className="text-xl font-bold text-info">{calcAffixBonus(subAffixOptions[v.affixId], v.stepCount, v.rollCount)}{mappingStats?.[subAffixOptions[v.affixId]?.property]?.unit || ""}</div>
</div>
{/* Up Roll Values */}
<div className="col-span-12">
<div className="flex items-center gap-2 mb-2">
<ChevronUp className="w-4 h-4 text-success" />
<span className="text-sm font-semibold text-success">{transI18n("upRoll")}</span>
</div>
<div className="grid grid-cols-3 gap-1">
<button
onClick={() => handleSubStatChange(v.affixId, index, v.rollCount + 1, v.stepCount + 0)}
className="btn btn-sm bg-white text-slate-800 hover:bg-gray-200 border-0"
>
{calcAffixBonus(subAffixOptions[v.affixId], 0, v.rollCount + 1)}{mappingStats?.[subAffixOptions[v.affixId]?.property]?.unit || ""}
</button>
<button
onClick={() => handleSubStatChange(v.affixId, index, v.rollCount + 1, v.stepCount + 1)}
className="btn btn-sm bg-white text-slate-800 hover:bg-gray-200 border-0"
>
{calcAffixBonus(subAffixOptions[v.affixId], v.stepCount + 1, v.rollCount + 1)}{mappingStats?.[subAffixOptions[v.affixId]?.property]?.unit || ""}
</button>
<button
onClick={() => handleSubStatChange(v.affixId, index, v.rollCount + 1, v.stepCount + 2)}
className="btn btn-sm bg-white text-slate-800 hover:bg-gray-200 border-0"
>
{calcAffixBonus(subAffixOptions[v.affixId], v.stepCount + 2, v.rollCount + 1)}{mappingStats?.[subAffixOptions[v.affixId]?.property]?.unit || ""}
</button>
</div>
</div>
{/* Down Roll Values */}
<div className="col-span-12">
<div className="flex items-center gap-2 mb-2">
<ChevronDown className="w-4 h-4 text-error" />
<span className="text-sm font-semibold text-error">{transI18n("downRoll")}</span>
</div>
<div className="grid grid-cols-3 gap-1">
<button
onClick={() => handleSubStatChange(v.affixId, index, Math.max(v.rollCount - 1, 0), Math.max(v.stepCount, 0))}
className="btn btn-sm bg-white text-slate-800 hover:bg-gray-200 border-0"
>
{calcAffixBonus(subAffixOptions[v.affixId], 0, Math.max(v.rollCount - 1, 0))}{mappingStats?.[subAffixOptions[v.affixId]?.property]?.unit || ""}
</button>
<button
onClick={() => handleSubStatChange(v.affixId, index, Math.max(v.rollCount - 1, 0), Math.max(v.stepCount - 1, 0))}
className="btn btn-sm bg-white text-slate-800 hover:bg-gray-200 border-0"
>
{calcAffixBonus(subAffixOptions[v.affixId], Math.max(v.stepCount - 1, 0), Math.max(v.rollCount - 1, 0))}{mappingStats?.[subAffixOptions[v.affixId]?.property]?.unit || ""}
</button>
<button
onClick={() => handleSubStatChange(v.affixId, index, Math.max(v.rollCount - 1, 0), Math.max(v.stepCount - 2, 0))}
className="btn btn-sm bg-white text-slate-800 hover:bg-gray-200 border-0"
>
{calcAffixBonus(subAffixOptions[v.affixId], Math.max(v.stepCount - 2, 0), Math.max(v.rollCount - 1, 0))}{mappingStats?.[subAffixOptions[v.affixId]?.property]?.unit || ""}
</button>
</div>
</div>
{/* Reset Button & Roll Info */}
<div className="col-span-12 text-center w-full">
<div className="grid grid-rows-2 gap-1 items-center justify-items-start w-full">
<div className="grid grid-cols-2 gap-2 items-center w-full">
<button
className="btn btn-error btn-sm mb-1"
onClick={() => resetSubStat(index)}
>
{transI18n("reset")}
</button>
<button
className="btn btn-warning btn-sm mb-1"
onClick={() => handlerRollback(index)}
>
{transI18n("rollBack")}
</button>
</div>
<div className="grid grid-cols-2 gap-2 items-center w-full">
<span className="font-bold">{transI18n("roll")}: <span className="text-info">{v.rollCount}</span></span>
<span className="font-bold">{transI18n("step")}: <span className="text-info">{v.stepCount}</span></span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,369 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import RelicMaker from "../relicBar";
import { motion } from "framer-motion";
import useUserDataStore from "@/stores/userDataStore";
import { useTranslations } from "next-intl";
import RelicCard from "../card/relicCard";
import useAvatarStore from "@/stores/avatarStore";
import useRelicStore from "@/stores/relicStore";
import useModelStore from '@/stores/modelStore';
import { replaceByParam } from '@/helper';
import useRelicMakerStore from '@/stores/relicMakerStore';
import useAffixStore from '@/stores/affixStore';
import QuickView from "../quickView";
export default function RelicsInfo() {
const { avatars, setAvatars } = useUserDataStore()
const { avatarSelected } = useAvatarStore()
const {
setSelectedRelicSlot,
selectedRelicSlot,
setSelectedMainStat,
setSelectedRelicSet,
setSelectedRelicLevel,
setListSelectedSubStats,
resetHistory,
resetSubStat,
listSelectedSubStats,
} = useRelicMakerStore()
const { mapSubAffix } = useAffixStore()
const {
isOpenRelic,
setIsOpenRelic,
isOpenQuickView,
setIsOpenQuickView
} = useModelStore()
const transI18n = useTranslations("DataPage")
const { mapRelicInfo } = useRelicStore()
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.showModal();
}
};
// Close modal handler
const handleCloseModal = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.close();
}
};
// Handle ESC key to close modal
useEffect(() => {
if (!isOpenRelic) {
handleCloseModal("action_detail_modal");
return;
};
if (!isOpenQuickView) {
handleCloseModal("quick_view_modal");
return;
};
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpenRelic) {
handleCloseModal("action_detail_modal");
}
if (event.key === 'Escape' && isOpenQuickView) {
handleCloseModal("quick_view_modal");
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpenRelic]);
const getRelic = useCallback((slot: string) => {
const avatar = avatars[avatarSelected?.id || ""];
if (avatar) {
return avatar.profileList[avatar.profileSelect]?.relics[slot] || null;
}
return null;
}, [avatars, avatarSelected]);
const handlerDeleteRelic = (slot: string) => {
const avatar = avatars[avatarSelected?.id || ""];
if (avatar) {
delete avatar.profileList[avatar.profileSelect].relics[slot]
setAvatars({ ...avatars });
}
}
const handlerChangeRelic = (slot: string) => {
const relic = getRelic(slot)
setSelectedRelicSlot(slot)
resetSubStat()
resetHistory(null)
if (relic) {
setSelectedMainStat(relic.main_affix_id.toString())
setSelectedRelicSet(relic.relic_set_id.toString())
setSelectedRelicLevel(relic.level)
const newSubAffixes: { affixId: string, property: string, rollCount: number, stepCount: number }[] = [...listSelectedSubStats];
relic.sub_affixes.forEach((item, index) => {
newSubAffixes[index].affixId = item.sub_affix_id.toString();
newSubAffixes[index].property = mapSubAffix["5"][item.sub_affix_id.toString()]?.property || "";
newSubAffixes[index].rollCount = item.count || 0;
newSubAffixes[index].stepCount = item.step || 0;
})
setListSelectedSubStats(newSubAffixes)
} else {
setSelectedMainStat("")
setSelectedRelicSet("")
setSelectedRelicLevel(15)
const newSubAffixes: { affixId: string, property: string, rollCount: number, stepCount: number }[] = [...listSelectedSubStats];
newSubAffixes.forEach((item) => {
item.affixId = ""
item.property = ""
item.rollCount = 0
item.stepCount = 0
})
setListSelectedSubStats(newSubAffixes)
}
setIsOpenRelic(true)
handleShow("action_detail_modal")
}
const relicEffects = useMemo(() => {
const avatar = avatars[avatarSelected?.id || ""];
const relicCount: { [key: string]: number } = {};
if (avatar) {
for (const relic of Object.values(avatar.profileList[avatar.profileSelect].relics)) {
if (relicCount[relic.relic_set_id]) {
relicCount[relic.relic_set_id]++;
} else {
relicCount[relic.relic_set_id] = 1;
}
}
}
const listEffects: { key: string, count: number }[] = [];
Object.entries(relicCount).forEach(([key, value]) => {
if (value >= 2) {
listEffects.push({ key: key, count: value });
}
});
return listEffects;
}, [avatars, avatarSelected]);
const modalConfigs = [
{
id: "action_detail_modal",
title: null, // không có title
onClose: () => {
setIsOpenRelic(false)
handleCloseModal("action_detail_modal")
},
content: <RelicMaker />
},
{
id: "quick_view_modal",
title: transI18n("quickView").toUpperCase(),
onClose: () => {
setIsOpenQuickView(false)
handleCloseModal("quick_view_modal")
},
content: <QuickView />
}
]
return (
<div className="max-h-[77vh] min-h-[50vh] overflow-y-scroll overflow-x-hidden">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Section - Items Grid */}
<div className="lg:col-span-2">
<div className="bg-base-100 rounded-xl p-6 shadow-lg">
<h2 className="flex items-center gap-2 text-2xl font-bold mb-6 text-base-content">
<div className="w-2 h-6 bg-linear-to-b from-primary to-primary/50 rounded-full"></div>
{transI18n("relics")}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6 max-w-2xl mx-auto">
{["1", "2", "3", "4", "5", "6"].map((item, index) => (
<div key={index} className="relative group">
<div
onClick={() => {
if (item === selectedRelicSlot) {
setSelectedRelicSlot("")
} else {
setSelectedRelicSlot(item)
}
handlerChangeRelic(item)
}}
className="cursor-pointer"
>
<RelicCard
slot={item}
avatarId={avatarSelected?.id || ""}
/>
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex gap-1">
<button
onClick={(e) => {
e.stopPropagation()
handlerChangeRelic(item)
}}
className="btn btn-info p-1.5 rounded-full shadow-lg transition-colors duration-200"
title={transI18n("changeRelic")}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</button>
{getRelic(item) && (
<button
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`${transI18n("deleteRelicConfirm")} ${item}?`)) {
handlerDeleteRelic(item)
}
}}
className="btn btn-error p-1.5 rounded-full shadow-lg transition-colors duration-200"
title={transI18n("deleteRelic")}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-2 mt-10">
<button
disabled={!selectedRelicSlot}
onClick={() => {
handlerChangeRelic(selectedRelicSlot)
}}
className="btn btn-info"
>
{transI18n("changeRelic")}
</button>
<button
disabled={!selectedRelicSlot}
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`${transI18n("deleteRelicConfirm")} ${selectedRelicSlot}?`)) {
handlerDeleteRelic(selectedRelicSlot)
}
}}
className="btn btn-error"
>
{transI18n("deleteRelic")}
</button>
</div>
<button
onClick={() => {
setIsOpenQuickView(true)
handleShow("quick_view_modal")
}}
className="btn btn-info w-full mt-2"
>
{transI18n("quickView")}
</button>
</div>
</div>
{/* Right Section - Stats and Set Effects */}
<div className="space-y-6">
{/* Set Effects Panel */}
<div className="bg-base-100 rounded-xl p-6 shadow-lg">
<h3 className="flex items-center gap-2 text-xl font-bold mb-4 text-base-content">
<div className="w-2 h-6 bg-linear-to-b from-primary to-primary/50 rounded-full"></div>
{transI18n("setEffects")}
</h3>
<div className="space-y-6">
{relicEffects.map((setEffect, index) => {
const relicInfo = mapRelicInfo[setEffect.key];
if (!relicInfo) return null;
return (
<div key={index} className="space-y-3">
<div className="flex items-center gap-2">
<div
className="font-bold text-warning"
dangerouslySetInnerHTML={{
__html: replaceByParam(
relicInfo.Name,
[]
)
}}
/>
{setEffect.count && (
<span className={`text-sm text-info`}>
({setEffect.count})
</span>
)}
</div>
<div className="space-y-2 pl-4">
{Object.entries(relicInfo.RequireNum).map(([requireNum, value]) => {
if (Number(requireNum) > Number(setEffect.count)) return null;
return (
<div key={requireNum} className="space-y-1">
<div className={`font-medium text-success`}>
{requireNum}-PC:
</div>
<div
className="text-sm text-base-content/80 leading-relaxed pl-4"
dangerouslySetInnerHTML={{
__html: replaceByParam(
value.Desc,
value.ParamList || []
)
}}
/>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</div>
{modalConfigs.map(({ id, title, onClose, content }) => (
<dialog key={id} id={id} className="modal">
<div className="modal-box w-11/12 max-w-7xl max-h-[85vh] bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="sticky top-0 z-10">
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
onClick={onClose}
>
</motion.button>
</div>
{title && (
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-linear-to-r from-pink-400 to-cyan-400">
{title}
</h3>
</div>
)}
{content}
</div>
</dialog>
))}
</div>
);
}

View File

@@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import Select, { SingleValue } from 'react-select'
import Image from 'next/image'
import useLocaleStore from '@/stores/localeStore'
import ParseText from '../parseText'
export type SelectOption = {
value: string
label: string
imageUrl: string
}
type SelectCustomProp = {
customSet: SelectOption[]
excludeSet: SelectOption[]
selectedCustomSet: string
placeholder: string
setSelectedCustomSet: (value: string) => void
}
export default function SelectCustomImage({ customSet, excludeSet, selectedCustomSet, placeholder, setSelectedCustomSet }: SelectCustomProp) {
const options: SelectOption[] = customSet
const { locale } = useLocaleStore()
const customStyles = {
option: (provided: any) => ({
...provided,
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
backgroundColor: 'transparent',
}),
singleValue: (provided: any) => ({
...provided,
display: 'flex',
alignItems: 'center',
gap: '8px',
backgroundColor: 'transparent',
}),
menuPortal: (provided: any) => ({ ...provided, zIndex: 9999 }),
menu: (provided: any) => ({ ...provided, zIndex: 9999 })
}
const formatOptionLabel = (option: SelectOption) => (
<div className="flex items-center gap-1 w-full h-full z-50">
<Image src={option.imageUrl} alt="" width={125} height={125} className="w-8 h-8 object-contain bg-warning-content rounded-full" />
<ParseText className='font-bold text-warning-content' text={option.label} locale={locale} />
</div>
)
return (
<Select
options={options.filter(opt => !excludeSet.some(ex => ex.value === opt.value))}
value={options.find(opt => {
return opt.value === selectedCustomSet}) || null}
onChange={(selected: SingleValue<SelectOption>) => {
setSelectedCustomSet(selected?.value || '')
}}
formatOptionLabel={formatOptionLabel}
styles={customStyles}
placeholder={placeholder}
className="my-react-select-container"
classNamePrefix="my-react-select"
isSearchable
isClearable
/>
)
}

View File

@@ -0,0 +1,83 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import Select, { SingleValue } from 'react-select'
import { replaceByParam } from '@/helper'
export type SelectOption = {
id: string
name: string
time?: string
description?: string
}
type SelectCustomProp = {
customSet: SelectOption[]
excludeSet: SelectOption[]
selectedCustomSet: string
placeholder: string
setSelectedCustomSet: (value: string) => void
}
export default function SelectCustomText({ customSet, excludeSet, selectedCustomSet, placeholder, setSelectedCustomSet }: SelectCustomProp) {
const options: SelectOption[] = customSet
const customStyles = {
option: (provided: any) => ({
...provided,
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
backgroundColor: 'transparent',
}),
singleValue: (provided: any) => ({
...provided,
display: 'flex',
alignItems: 'center',
gap: '8px'
}),
menuPortal: (provided: any) => ({ ...provided, zIndex: 9999 }),
menu: (provided: any) => ({ ...provided, zIndex: 9999 })
}
const formatOptionLabel = (option: SelectOption) => (
<div className="flex flex-col gap-1 w-full h-full z-50 text-warning-content ">
<div
className="font-bold text-lg"
dangerouslySetInnerHTML={{
__html: replaceByParam(
option.name,
[]
)
}}
/>
{option.time && <div className='text-base'>{option.time}</div>}
{option.description && <div
className="text-base"
dangerouslySetInnerHTML={{
__html: option.description
}}
/>}
</div>
)
return (
<Select
options={options.filter(opt => !excludeSet.some(ex => ex.id === opt.id))}
value={options.find(opt => {
return opt.id === selectedCustomSet
}) || null}
onChange={(selected: SingleValue<SelectOption>) => {
setSelectedCustomSet(selected?.id || '')
}}
formatOptionLabel={formatOptionLabel}
styles={customStyles}
placeholder={placeholder}
className="my-react-select-container"
classNamePrefix="my-react-select"
isSearchable
isClearable
/>
)
}

View File

@@ -0,0 +1,933 @@
"use client";
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import useAvatarStore from "@/stores/avatarStore";
import { FastAverageColor, FastAverageColorResult } from 'fast-average-color';
import NextImage from 'next/image';
import ParseText from '../parseText';
import useLocaleStore from '@/stores/localeStore';
import { calcAffixBonus, calcBaseStat, calcBaseStatRaw, calcBonusStatRaw, calcMainAffixBonus, calcMainAffixBonusRaw, calcPromotion, calcSubAffixBonusRaw, convertToRoman, getNameChar, replaceByParam } from '@/helper';
import useUserDataStore from '@/stores/userDataStore';
import { traceShowCaseMap } from '@/constant/traceConstant';
import { StatusAddType } from '@/types';
import { mappingStats } from '@/constant/constant';
import useLightconeStore from '@/stores/lightconeStore';
import { useTranslations } from 'next-intl';
import useAffixStore from '@/stores/affixStore';
import useRelicStore from '@/stores/relicStore';
import { toast } from 'react-toastify';
import RelicShowcase from './relicShowcase';
export default function ShowCaseInfo() {
const { avatarSelected, mapAvatarInfo } = useAvatarStore()
const { mapLightconeInfo } = useLightconeStore()
const { mapMainAffix, mapSubAffix } = useAffixStore()
const { avatars } = useUserDataStore()
const [avgColor, setAvgColor] = useState('#222');
const imgRef = useRef(null);
const cardRef = useRef(null)
const { locale } = useLocaleStore()
const transI18n = useTranslations("DataPage")
const { mapRelicInfo } = useRelicStore()
const handleSaveImage = useCallback(() => {
if (cardRef.current === null || !avatarSelected) {
toast.error("Avatar showcase not found!");
return;
}
import("html2canvas-pro")
.then(({ default: html2canvas }) =>
html2canvas(cardRef.current!, {
scale: 2,
backgroundColor: "#000000",
logging: false,
proxy: '/api/proxy/',
imageTimeout: 30000,
})
)
.then((canvas: HTMLCanvasElement) => {
const link = document.createElement("a");
link.download = `${getNameChar(locale, avatarSelected)}_showcase.png`;
link.href = canvas.toDataURL("image/png");
link.click();
})
.catch(() => {
toast.error("Error generating showcase card!");
});
}, [cardRef, avatarSelected, locale]);
useEffect(() => {
if (!avatarSelected?.id) return;
const fac = new FastAverageColor();
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = `https://api.hakush.in/hsr/UI/avatardrawcard/${avatarSelected.id}.webp`;
img.onload = () => {
fac.getColorAsync(img).then((color: FastAverageColorResult) => {
setAvgColor(color.hex); // #RRGGBB
});
};
}, [avatarSelected]);
const avatarInfo = useMemo(() => {
if (!avatarSelected) return
return mapAvatarInfo[avatarSelected.id]
}, [avatarSelected, mapAvatarInfo])
const avatarSkillTree = useMemo(() => {
if (!avatarSelected || !avatars[avatarSelected.id]) return {}
if (avatars[avatarSelected.id].enhanced) {
return avatarInfo?.Enhanced[avatars[avatarSelected.id].enhanced.toString()].SkillTrees || {}
}
return avatarInfo?.SkillTrees || {}
}, [avatarSelected, avatarInfo, avatars])
const avatarData = useMemo(() => {
if (!avatarSelected) return
return avatars[avatarSelected.id]
}, [avatarSelected, avatars])
const avatarProfile = useMemo(() => {
if (!avatarSelected || !avatarData) return
return avatarData?.profileList?.[avatarData?.profileSelect]
}, [avatarSelected, avatarData])
const lightconeStats = useMemo(() => {
if (!avatarSelected || !avatarProfile?.lightcone || !mapLightconeInfo[avatarProfile?.lightcone?.item_id]) return
const promotion = calcPromotion(avatarProfile?.lightcone?.level)
const atkStat = calcBaseStat(
mapLightconeInfo[avatarProfile?.lightcone?.item_id].Stats[promotion].BaseAttack,
mapLightconeInfo[avatarProfile?.lightcone?.item_id].Stats[promotion].BaseAttackAdd,
0,
avatarProfile?.lightcone?.level
)
const hpStat = calcBaseStat(
mapLightconeInfo[avatarProfile?.lightcone?.item_id].Stats[promotion].BaseHP,
mapLightconeInfo[avatarProfile?.lightcone?.item_id].Stats[promotion].BaseHPAdd,
0,
avatarProfile?.lightcone?.level
)
const defStat = calcBaseStat(
mapLightconeInfo[avatarProfile?.lightcone?.item_id].Stats[promotion].BaseDefence,
mapLightconeInfo[avatarProfile?.lightcone?.item_id].Stats[promotion].BaseDefenceAdd,
0,
avatarProfile?.lightcone?.level
)
return {
attack: atkStat,
hp: hpStat,
def: defStat,
}
}, [avatarSelected, mapLightconeInfo, avatarProfile])
const relicEffects = useMemo(() => {
const avatar = avatars[avatarSelected?.id || ""];
const relicCount: { [key: string]: number } = {};
if (avatar) {
for (const relic of Object.values(avatar.profileList[avatar.profileSelect].relics)) {
if (relicCount[relic.relic_set_id]) {
relicCount[relic.relic_set_id]++;
} else {
relicCount[relic.relic_set_id] = 1;
}
}
}
const listEffects: { key: string, count: number }[] = [];
Object.entries(relicCount).forEach(([key, value]) => {
if (value >= 2) {
listEffects.push({ key: key, count: value });
}
});
return listEffects;
}, [avatars, avatarSelected]);
const relicStats = useMemo(() => {
if (!avatarSelected || !avatarProfile?.relics || !mapMainAffix || !mapSubAffix) return
return Object.entries(avatarProfile?.relics).map(([key, value]) => {
const mainAffixMap = mapMainAffix["5" + key]
const subAffixMap = mapSubAffix["5"]
if (!mainAffixMap || !subAffixMap) return
return {
img: `https://api.hakush.in/hsr/UI/relicfigures/IconRelic_${value.relic_set_id}_${key}.webp`,
mainAffix: {
property: mainAffixMap?.[value?.main_affix_id]?.property,
level: value?.level,
valueAffix: calcMainAffixBonus(mainAffixMap?.[value?.main_affix_id], value?.level),
detail: mappingStats?.[mainAffixMap?.[value?.main_affix_id]?.property]
},
subAffix: value?.sub_affixes?.map((subValue) => {
return {
property: subAffixMap?.[subValue?.sub_affix_id]?.property,
valueAffix: calcAffixBonus(subAffixMap?.[subValue?.sub_affix_id], subValue?.step, subValue?.count),
detail: mappingStats?.[subAffixMap?.[subValue?.sub_affix_id]?.property],
step: subValue?.step,
count: subValue?.count
}
})
}
})
}, [avatarSelected, avatarProfile, mapMainAffix, mapSubAffix])
const totalSubStats = useMemo(() => {
if (!relicStats?.length) return 0
return (relicStats ?? []).reduce((acc, relic) => {
const subAffixList = relic?.subAffix ?? []
return acc + subAffixList.reduce((subAcc, subAffix) => {
if (avatarInfo?.Relics?.SubAffixPropertyList.findIndex(it => it === subAffix.property) !== -1) {
return subAcc + (subAffix?.count ?? 0)
}
return subAcc
}, 0)
}, 0)
}, [relicStats, avatarInfo])
const characterStats = useMemo(() => {
if (!avatarSelected || !avatarData) return
const charPromotion = calcPromotion(avatarData.level)
const statsData: Record<string, {
value: number,
base: number,
name: string,
icon: string,
unit: string,
round: number
}> = {
HP: {
value: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPAdd,
avatarData.level
),
base: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.HPAdd,
avatarData.level
),
name: "HP",
icon: "/icon/hp.webp",
unit: "",
round: 0
},
ATK: {
value: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackAdd,
avatarData.level
),
base: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.AttackAdd,
avatarData.level
),
name: "ATK",
icon: "/icon/attack.webp",
unit: "",
round: 0
},
DEF: {
value: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceAdd,
avatarData.level
),
base: calcBaseStatRaw(
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceBase,
mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.DefenceAdd,
avatarData.level
),
name: "DEF",
icon: "/icon/defence.webp",
unit: "",
round: 0
},
SPD: {
value: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.SpeedBase || 0,
base: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.SpeedBase || 0,
name: "SPD",
icon: "/icon/speed.webp",
unit: "",
round: 1
},
CRITRate: {
value: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalChance || 0,
base: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalChance || 0,
name: "CRIT Rate",
icon: "/icon/crit-rate.webp",
unit: "%",
round: 1
},
CRITDmg: {
value: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalDamage || 0,
base: mapAvatarInfo?.[avatarSelected.id]?.Stats[charPromotion]?.CriticalDamage || 0,
name: "CRIT DMG",
icon: "/icon/crit-damage.webp",
unit: "%",
round: 1
},
BreakEffect: {
value: 0,
base: 0,
name: "Break Effect",
icon: "/icon/break-effect.webp",
unit: "%",
round: 1
},
EffectRES: {
value: 0,
base: 0,
name: "Effect RES",
icon: "/icon/effect-res.webp",
unit: "%",
round: 1
},
EnergyRate: {
value: 0,
base: 0,
name: "Energy Rate",
icon: "/icon/energy-rate.webp",
unit: "%",
round: 1
},
EffectHitRate: {
value: 0,
base: 0,
name: "Effect Hit Rate",
icon: "/icon/effect-hit-rate.webp",
unit: "%",
round: 1
},
HealBoost: {
value: 0,
base: 0,
name: "Healing Boost",
icon: "/icon/healing-boost.webp",
unit: "%",
round: 1
},
PhysicalAdd: {
value: 0,
base: 0,
name: "Physical Boost",
icon: "/icon/physical-add.webp",
unit: "%",
round: 1
},
FireAdd: {
value: 0,
base: 0,
name: "Fire Boost",
icon: "/icon/fire-add.webp",
unit: "%",
round: 1
},
IceAdd: {
value: 0,
base: 0,
name: "Ice Boost",
icon: "/icon/ice-add.webp",
unit: "%",
round: 1
},
ThunderAdd: {
value: 0,
base: 0,
name: "Thunder Boost",
icon: "/icon/thunder-add.webp",
unit: "%",
round: 1
},
WindAdd: {
value: 0,
base: 0,
name: "Wind Boost",
icon: "/icon/wind-add.webp",
unit: "%",
round: 1
},
QuantumAdd: {
value: 0,
base: 0,
name: "Quantum Boost",
icon: "/icon/quantum-add.webp",
unit: "%",
round: 1
},
ImaginaryAdd: {
value: 0,
base: 0,
name: "Imaginary Boost",
icon: "/icon/imaginary-add.webp",
unit: "%",
round: 1
},
}
if (avatarProfile?.lightcone && mapLightconeInfo[avatarProfile?.lightcone?.item_id]) {
const lightconePromotion = calcPromotion(avatarProfile?.lightcone?.level)
statsData.HP.value += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHP,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHPAdd,
avatarProfile?.lightcone?.level
)
statsData.HP.base += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHP,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHPAdd,
avatarProfile?.lightcone?.level
)
statsData.ATK.value += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttack,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttackAdd,
avatarProfile?.lightcone?.level
)
statsData.ATK.base += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttack,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseAttackAdd,
avatarProfile?.lightcone?.level
)
statsData.DEF.value += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefence,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefenceAdd,
avatarProfile?.lightcone?.level
)
statsData.DEF.base += calcBaseStatRaw(
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefence,
mapLightconeInfo?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseDefenceAdd,
avatarProfile?.lightcone?.level
)
const bonusData = mapLightconeInfo[avatarProfile?.lightcone?.item_id].Bonus?.[avatarProfile?.lightcone.rank - 1]
if (bonusData && bonusData.length > 0) {
const bonusSpd = bonusData.filter((bonus) => bonus.type === "BaseSpeed")
const bonusOther = bonusData.filter((bonus) => bonus.type !== "BaseSpeed")
bonusSpd.forEach((bonus) => {
statsData.SPD.value += bonus.value
statsData.SPD.base += bonus.value
})
bonusOther.forEach((bonus) => {
const statsBase = mappingStats?.[bonus.type]?.baseStat
if (statsBase && statsData[statsBase]) {
statsData[statsBase].value += calcBonusStatRaw(bonus.type, statsData[statsBase].base, bonus.value)
}
})
}
}
if (avatarSkillTree) {
Object.values(avatarSkillTree).forEach((value) => {
if (value?.["1"]
&& value?.["1"]?.PointID
&& typeof avatarData?.data?.skills?.[value?.["1"]?.PointID] === "number"
&& avatarData?.data?.skills?.[value?.["1"]?.PointID] !== 0
&& value?.["1"]?.StatusAddList
&& value?.["1"].StatusAddList.length > 0) {
value?.["1"]?.StatusAddList.forEach((status) => {
const statsBase = mappingStats?.[status?.PropertyType]?.baseStat
if (statsBase && statsData[statsBase]) {
statsData[statsBase].value += calcBonusStatRaw(status?.PropertyType, statsData[statsBase].base, status.Value)
}
})
}
})
}
if (avatarProfile?.relics && mapMainAffix && mapSubAffix) {
Object.entries(avatarProfile?.relics).forEach(([key, value]) => {
const mainAffixMap = mapMainAffix["5" + key]
const subAffixMap = mapSubAffix["5"]
if (!mainAffixMap || !subAffixMap) return
const mainStats = mappingStats?.[mainAffixMap?.[value.main_affix_id]?.property]?.baseStat
if (mainStats && statsData[mainStats]) {
statsData[mainStats].value += calcMainAffixBonusRaw(mainAffixMap?.[value.main_affix_id], value.level, statsData[mainStats].base)
}
value?.sub_affixes.forEach((subValue) => {
const subStats = mappingStats?.[subAffixMap?.[subValue.sub_affix_id]?.property]?.baseStat
if (subStats && statsData[subStats]) {
statsData[subStats].value += calcSubAffixBonusRaw(subAffixMap?.[subValue.sub_affix_id], subValue.step, subValue.count, statsData[subStats].base)
}
})
})
}
if (relicEffects && relicEffects.length > 0) {
relicEffects.forEach((relic) => {
const dataBonus = mapRelicInfo?.[relic.key]?.Bonus
if (!dataBonus || Object.keys(dataBonus).length === 0) return
Object.entries(dataBonus || {}).forEach(([key, value]) => {
if (relic.count < Number(key)) return
value.forEach((bonus) => {
const statsBase = mappingStats?.[bonus.type]?.baseStat
if (statsBase && statsData[statsBase]) {
statsData[statsBase].value += calcBonusStatRaw(bonus.type, statsData[statsBase].base, bonus.value)
}
})
})
})
}
return statsData
}, [
avatarSelected,
avatarData,
mapAvatarInfo,
avatarProfile?.lightcone,
avatarProfile?.relics,
mapLightconeInfo,
mapMainAffix,
mapSubAffix,
relicEffects,
mapRelicInfo,
avatarSkillTree
])
const applyBrightness = useCallback((hex: string, brightness: number): string => {
const r = Math.round(parseInt(hex.slice(1, 3), 16) * brightness);
const g = Math.round(parseInt(hex.slice(3, 5), 16) * brightness);
const b = Math.round(parseInt(hex.slice(5, 7), 16) * brightness);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}, [])
const getImageSkill = useCallback((icon: string | undefined, status: StatusAddType | undefined) => {
if (!icon) return
if (icon.startsWith("SkillIcon")) {
if (Number(avatarSelected?.id) > 8000 && Number(avatarSelected?.id) % 2 === 0) {
return `https://homdgcat.wiki/images/skillicons/avatar/${Number(avatarSelected?.id) - 1}/${icon.replaceAll(avatarSelected?.id || "", (Number(avatarSelected?.id) - 1).toString())}`
}
return `https://homdgcat.wiki/images/skillicons/avatar/${avatarSelected?.id}/${icon}`
} else if (status && mappingStats[status.PropertyType]) {
return mappingStats[status.PropertyType].icon
}
else if (icon.startsWith("Icon")) {
return `https://api.hakush.in/hsr/UI/trace/${icon.replace(".png", ".webp")}`
}
return ""
}, [avatarSelected])
return (
<div className="flex flex-col justify-start m-1 text-white">
<div className="flex items-center justify-start mt-4 mb-4">
<button className="btn btn-success w-24 text-sm" onClick={handleSaveImage}>Save Img</button>
</div>
<div className="overflow-auto">
<div
ref={cardRef}
className=" relative min-h-[650px] w-[1600px] rounded-3xl transition-all duration-500 overflow-hidden"
style={{
backgroundColor: `${applyBrightness(avgColor, 0.3)}`,
backdropFilter: "blur(50px)",
WebkitBackdropFilter: "blur(50px)",
}}
>
<div className="absolute bottom-2 left-4 z-10">
<span className="shadow-black [text-shadow:1px_1px_2px_var(--tw-shadow-color)]"></span>
</div>
<div className="flex flex-row items-center">
<div
className="relative min-h-[650px] w-[24%]"
>
<div className="flex justify-center items-center w-full h-full overflow-hidden">
{avatarSelected && (
<NextImage
ref={imgRef}
src={`https://api.hakush.in/hsr/UI/avatardrawcard/${avatarSelected?.id}.webp`}
className="object-cover scale-[2] overflow-hidden"
alt="Character Preview"
width={1024}
height={1024}
priority={true}
style={{
position: 'absolute',
top: `130px`,
left: `0px`,
}}
/>
)}
</div>
</div>
<div
className="relative flex min-h-[650px] w-[76%] flex-row items-center gap-3.5 rounded-3xl pl-10 z-10 transition-all duration-500"
style={{
backgroundColor: `${applyBrightness(avgColor, 0.5)}`,
backdropFilter: "blur(50px)",
WebkitBackdropFilter: "blur(50px)",
}}
>
<div className="absolute top-4 left-4">
{avatarSelected && avatarInfo && avatarData?.data && typeof avatarData?.data?.rank === "number" && (
<div className="flex flex-col">
{avatarInfo?.RankIcon.map((src, index) => {
const isActive = avatarData?.data?.rank > index;
return (
<div key={index} className="relative my-1 flex rounded-full">
<NextImage
src={src ?? null}
alt="Rank Icon"
width={50}
height={50}
className="h-auto w-12 transition-all duration-300 ease-in-out p-[1px] rounded-full"
style={{
opacity: isActive ? 1 : 0.6,
filter: isActive
? 'grayscale(0) drop-shadow(0 0 8px rgba(255,215,0,0.5))'
: 'grayscale(1)',
border: '2px solid',
borderColor: isActive ? '#facc15' : '#ffffff', // tailwind yellow-400 = #facc15
boxShadow: isActive ? '0 0 10px rgba(250,204,21,0.5)' : undefined, // mimic shadow-yellow-400/50
}}
/>
</div>
);
})}
</div>
)}
</div>
<div className="flex h-[650px] w-1/3 flex-col justify-between py-3 pl-8">
<div className="flex h-full flex-col justify-between">
<div>
<div className="flex flex-row items-center justify-between">
<ParseText className="text-3xl" text={getNameChar(locale, avatarSelected || undefined)} locale={locale} />
</div>
<div className="flex flex-row items-center gap-4 mt-2">
<div className="text-2xl text-[#d8b46e]">Lv. <span className="text-white">{avatarData?.level}</span>/<span className="text-neutral-400">80</span></div>
<span className="px-1.5 py-0.5 rounded-full text-lg font-bold text-[#E6D5B5] bg-[#5c4022] border-2 border-[#d8b46e] shadow-[0_0_10px_rgba(250,204,21,0.3)] select-none">
{totalSubStats}
</span>
{avatarSelected && (
<div className="flex gap-1">
<NextImage src={`/icon/${avatarSelected?.baseType.toLowerCase()}.webp`} alt="Path Icon" width={32} height={32} className="h-auto w-8" />
<NextImage src={`/icon/${avatarSelected?.damageType.toLowerCase()}.webp`} alt="Element Icon" width={32} height={32} className="h-auto w-8" />
</div>
)}
</div>
</div>
<div className="relative flex h-[225px] w-auto flex-row items-center">
{avatarSelected && (
<div className="absolute inset-0 flex items-center justify-center">
<NextImage src={`/icon/${avatarSelected?.baseType.toLowerCase()}.webp`} alt="Path Icon" width={160} height={160} className="h-40 w-40 opacity-20" />
</div>
)}
<div className="flex flex-col gap-4">
{avatarData && avatarInfo && avatarSkillTree && traceShowCaseMap[avatarSelected?.baseType || ""]
&& Object.values(traceShowCaseMap[avatarSelected?.baseType || ""] || []).map((item, index) => {
return (
<div key={`row-${index}`} className="flex flex-row items-center">
{item.map((btn, idx) => {
const size = btn.size || "small";
const isBig = size === "big";
const isMedium = size === "medium";
const isBigMemory = size === "big-memory";
const sizeClass = isBigMemory ? "w-12 h-12 mx-1" : isBig ? "w-12 h-12 mx-1" : isMedium ? "w-10 h-10" : "w-8 h-8";
const imageSize = isBigMemory ? "w-10" : isBig ? "w-10" : isMedium ? "w-8" : "w-6";
const bgColor = isBigMemory
? "bg-[#2a1a39]/80"
: isBig
? "bg-[#2b1d00]/80"
: isMedium
? "bg-[#2b1d00]/50"
: "bg-[#000000]/50";
const filterClass = isBigMemory
? "filter sepia brightness-80 hue-rotate-[280deg] saturate-400 contrast-130"
: isBig
? "filter sepia brightness-150 hue-rotate-15 saturate-200"
: "";
if (!avatarSkillTree?.[btn.id]) {
return null;
}
return (
<div key={`item-${idx}`} className="relative flex flex-row items-center">
<div
className={
`relative flex items-center justify-center ${sizeClass}
rounded-full ${bgColor} border-2 border-gray-500
${avatarData.data.skills[avatarSkillTree?.[btn.id]?.["1"]?.PointID] ? "" : "opacity-50"}
`}
>
{
(() => {
const skillImg = getImageSkill(
avatarInfo.SkillTrees?.[btn.id]?.["1"]?.Icon,
avatarSkillTree?.[btn.id]?.["1"]?.StatusAddList[0]
);
return skillImg ? (
<NextImage
src={skillImg}
alt={btn.id}
width={32}
height={32}
className={`h-auto ${imageSize} ${filterClass}`}
/>
) : null;
})()
}
{(isBig || isBigMemory) && (
<span className="absolute bottom-0 left-0 text-[12px] text-white bg-black/70 px-1 rounded-sm">
{avatarData?.data?.skills?.[avatarSkillTree?.[btn.id]?.["1"]?.PointID] ? avatarData?.data?.skills?.[avatarSkillTree?.[btn.id]?.["1"]?.PointID] : 1}
</span>
)}
</div>
{btn.isLink && idx < item.length - 1 && (
<div className="w-3 h-[3px] bg-white opacity-80 mx-1" />
)}
</div>
);
})}
</div>
)
})}
</div>
</div>
{avatarProfile && avatarProfile?.lightcone && lightconeStats ? (
<div className="flex flex-row items-center justify-center mb-2">
<div className="relative w-36 h-48">
{/* Background SVG Border (offset top-left) */}
<svg
xmlns="http://www.w3.org/2000/svg"
className="absolute w-full h-full z-10"
style={{
color: '#f59e0b',
opacity: 0.7,
top: '0px',
left: '-1px'
}}
viewBox="0 0 5 7"
>
<path
fill="none"
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="0.065"
d="m.301.032-.269.25v6.436l.303.25h4.364l.269-.25V.282l-.269-.25z"
/>
</svg>
{/* Card Image */}
<NextImage
className="absolute object-cover rounded-xl z-9 w-[95%]"
src={`https://api.hakush.in/hsr/UI/lightconemaxfigures/${avatarProfile?.lightcone.item_id}.webp`}
alt="Lightcone Image"
width={904}
height={1206}
priority
style={{
top: '0px',
left: '6px',
}}
/>
{/* Top SVG Border (offset bottom-right) */}
<svg
xmlns="http://www.w3.org/2000/svg"
className="absolute w-full h-full z-8"
style={{
color: '#f59e0b',
opacity: 0.8,
bottom: '0px',
right: '-4px'
}}
viewBox="0 0 5 7"
>
<path
fill="none"
stroke="currentColor"
strokeOpacity="0.9"
strokeWidth="0.065"
d="m.301.032-.269.25v6.436l.303.25h4.364l.269-.25V.282l-.269-.25z"
/>
<path
d="M.34.004 0 .268v6.464l.33.233L4.71 7 5 6.732V.268L4.71 0Zm.01.06L4.693.03l.22.268.023 6.406-.25.233-4.34.02-.25-.253L.018.297z"
style={{
fill: 'currentColor',
opacity: 0.3
}}
/>
</svg>
{/* Stars */}
<div
className="absolute text-yellow-500 font-bold z-10"
style={{
writingMode: 'vertical-rl',
left: '-0.5rem',
top: '0.5rem',
fontSize: '1.1rem',
letterSpacing: '-0.1em',
textShadow: `
0 0 0.2em #f59e0b,
0 0 0.4em #f59e0b,
0 0 0.8em #f59e0b,
-0.05em -0.05em 0.05em rgba(0,0,0,0.7),
0.05em 0.05em 0.05em rgba(0,0,0,0.7)
`
}}
>
{[...Array(
Number(
mapLightconeInfo[avatarProfile?.lightcone?.item_id]?.Rarity?.[
mapLightconeInfo[avatarProfile?.lightcone?.item_id]?.Rarity.length - 1
] || 0
)
)].map((_, i) => (
<span key={i}></span>
))}
</div>
</div>
<div className="flex w-2/5 flex-col gap-2 text-white ml-2 h-full">
<div className="flex flex-col h-full">
<div className="flex items-center h-full">
<div className="w-1 h-[70%] bg-yellow-400 mr-2 rounded" />
<ParseText
className="text-lg font-semibold"
locale={locale}
text={mapLightconeInfo[avatarProfile?.lightcone?.item_id].Name}
/>
</div>
<div className="flex items-center gap-2 mt-1 text-sm text-[#d8b46e]">
<div className="h-6 w-6 flex items-center justify-center rounded-full bg-[#5c4022] border border-[#d8b46e] text-[#d8b46e] text-xs font-medium">
{convertToRoman(avatarProfile?.lightcone?.rank)}
</div>
<span>
Lv. <span className="text-white">{avatarProfile.lightcone.level}</span>/<span className="text-neutral-400">80</span>
</span>
</div>
</div>
<div className="flex justify-center items-center flex-col gap-1 mt-1 ">
<div className="flex gap-1 text-sm ">
<div className="flex items-center gap-1 rounded bg-black/30 px-1 w-fit py-1">
<NextImage src="/icon/hp.webp" alt="HP" width={16} height={16} className="w-4 h-4" />
<span>{
lightconeStats?.hp
}</span>
</div>
<div className="flex items-center gap-1 rounded bg-black/30 px-1 w-fit py-1">
<NextImage src="/icon/attack.webp" alt="ATK" width={16} height={16} className="w-4 h-4" />
<span>{lightconeStats?.attack}</span>
</div>
</div>
<div className="flex items-center gap-1 rounded bg-black/30 px-1 w-fit py-1">
<NextImage src="/icon/defence.webp" alt="DEF" width={16} height={16} className="w-4 h-4" />
<span>{lightconeStats?.def}</span>
</div>
</div>
</div>
</div>
) : (<div className="flex h-1/4 items-center text-lg">
{transI18n("noLightconeEquipped")}
</div>)}
</div>
</div>
<div className="flex h-[650px] w-1/3 flex-col justify-between py-3 z-10">
<div className="flex w-full flex-col justify-between gap-y-0.5 text-base h-[500px]">
{Object.entries(characterStats || {})?.map(([key, stat], index) => {
if (!stat || (key.includes("Add") && stat.value === 0)) return null
return (
<div key={index} className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<NextImage src={stat?.icon || ""} alt="Stat Icon" width={40} height={40} className="h-auto w-10 p-2" />
<span className="font-bold">{stat.name}</span>
</div>
<div className="ml-3 mr-3 flex-grow border rounded opacity-50" />
<div className="flex cursor-default flex-col text-right font-bold">{
stat.value ? stat.unit === "%" ? (stat.value * 100).toFixed(stat.round) : stat.value.toFixed(stat.round) : 0
}{stat.unit}</div>
</div>
)
})}
<hr />
</div>
<div className="flex flex-col items-center gap-1 w-full my-2">
{relicEffects.map((setEffect, index) => {
const relicInfo = mapRelicInfo[setEffect.key];
if (!relicInfo) return null;
return (
<div key={index} className="flex w-full flex-row justify-between text-left">
<div
className="font-bold truncate max-w-full mr-1"
style={{
fontSize: 'clamp(0.5rem, 2vw, 1rem)'
}}
dangerouslySetInnerHTML={{
__html: replaceByParam(
relicInfo.Name,
[]
)
}}
/>
<div>
<span className="black-blur bg-black/30 flex w-5 justify-center rounded px-1.5 py-0.5">{setEffect.count}</span>
</div>
</div>
)
})}
</div>
</div>
<div className="w-1/3 z-10">
<div className="flex h-[650px] flex-col justify-between py-3 mr-1 text-lg w-full" >
{relicStats?.map((relic, index) => {
if (!relic || !avatarInfo) return null
return (
<RelicShowcase key={index} relic={relic} avatarInfo={avatarInfo} />
)
})}
{(!relicStats || !relicStats?.length) && (
<div className="flex flex-col items-center justify-center">
<div className="text-center p-6">
<span className="text-lg text-white">{transI18n("noRelicEquipped")}</span>
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
"use client"
import NextImage from "next/image"
import { CharacterDetail, RelicShowcaseType } from "@/types";
export default function RelicShowcase({
relic,
avatarInfo,
}: {
relic: RelicShowcaseType;
avatarInfo: CharacterDetail;
}) {
return (
<>
<div
className="relative w-full flex flex-row items-center rounded-s-lg border-l-2 p-1 border-yellow-600/60 bg-linear-to-r from-yellow-600/20 to-transparent"
>
{/* Subtle glow overlay */}
<div className="absolute inset-0 rounded-s-lg pointer-events-none"></div>
<div className="flex relative">
<div className="absolute inset-0 rounded-lg blur-lg -z-10"></div>
<NextImage
src={relic?.img || ""}
width={78}
height={78}
alt="Relic Icon"
className="h-auto w-[78px] rounded-lg"
/>
<div
className="absolute text-yellow-400 font-bold z-10 drop-shadow-[0_0_6px_rgba(251,191,36,0.8)]"
style={{
left: '0.65rem',
bottom: '-0.45rem',
fontSize: '1.05rem',
letterSpacing: '-0.1em',
}}
>
</div>
</div>
<div className=" flex w-1/6 flex-col items-center justify-center">
<div className="relative">
<div className="absolute inset-0 bg-yellow-500/15 rounded-full blur-md -z-10"></div>
<NextImage
src={relic?.mainAffix?.detail?.icon || ""}
width={35}
height={35}
alt="Main Affix Icon"
className="h-auto w-[35px]"
/>
</div>
<span className="text-base text-yellow-400 font-semibold drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]">
{relic?.mainAffix?.valueAffix + relic?.mainAffix?.detail?.unit}
</span>
<span className="bg-black/20 backdrop-blur-sm rounded px-1.5 py-0.5 text-xs border border-white/10">
+{relic?.mainAffix?.level}
</span>
</div>
<div style={{ opacity: 0.3, height: '78px', borderLeftWidth: '1px' }}></div>
<div className="grid w-[65%] m-2 grid-cols-2 gap-1">
{relic?.subAffix?.map((subAffix, index) => {
if (!subAffix) return null
return (
<div key={index} className="flex flex-col">
<div className="relative flex flex-row items-center bg-black/20 backdrop-blur-sm rounded-md p-1 border border-white/5 min-w-0">
{subAffix?.detail?.icon ? (
<NextImage
src={subAffix?.detail?.icon || ""}
width={32}
height={32}
alt="Sub Affix Icon"
className="h-auto w-6 flex-shrink-0"
/>
) : (
<div className="h-6 w-6 bg-black/60 rounded flex items-center justify-center border border-white/10 flex-shrink-0">
<span className="text-xs text-white/50">?</span>
</div>
)}
<span className="text-xs text-gray-200 ml-0.5 truncate flex-1 min-w-0">
+{subAffix?.valueAffix + subAffix?.detail?.unit}
</span>
{
(avatarInfo?.Relics?.SubAffixPropertyList.findIndex((item) => item === subAffix?.property) !== -1) && (
<span className="ml-1 bg-yellow-600/20 text-yellow-400 rounded-full px-1 py-0.5 text-[10px] font-semibold border border-yellow-600/30 flex-shrink-0 leading-none">
{subAffix?.count}
</span>
)}
</div>
</div>
)
})}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,396 @@
"use client"
import { useTranslations } from "next-intl";
import useAvatarStore from "@/stores/avatarStore";
import { useCallback, useMemo } from "react";
import { traceButtonsInfo, traceLink } from "@/constant/traceConstant";
import useUserDataStore from "@/stores/userDataStore";
import useLocaleStore from "@/stores/localeStore";
import Image from "next/image";
import { replaceByParam } from "@/helper";
import { mappingStats } from "@/constant/constant";
import { StatusAddType } from "@/types";
import { toast } from "react-toastify";
export default function SkillsInfo() {
const transI18n = useTranslations("DataPage")
const { theme } = useLocaleStore()
const { avatarSelected, mapAvatarInfo, skillSelected, setSkillSelected } = useAvatarStore()
const { avatars, setAvatar } = useUserDataStore()
const traceButtons = useMemo(() => {
if (!avatarSelected) return
return traceButtonsInfo[avatarSelected.baseType]
}, [avatarSelected])
const avatarInfo = useMemo(() => {
if (!avatarSelected) return
return mapAvatarInfo[avatarSelected.id]
}, [avatarSelected, mapAvatarInfo])
const avatarData = useMemo(() => {
if (!avatarSelected) return
return avatars[avatarSelected.id]
}, [avatarSelected, avatars])
const avatarSkillTree = useMemo(() => {
if (!avatarSelected || !avatars[avatarSelected.id]) return {}
if (avatars[avatarSelected.id].enhanced) {
return avatarInfo?.Enhanced[avatars[avatarSelected.id].enhanced.toString()].SkillTrees || {}
}
return avatarInfo?.SkillTrees || {}
}, [avatarSelected, avatarInfo, avatars])
const skillInfo = useMemo(() => {
if (!avatarSelected || !skillSelected) return
return avatarSkillTree?.[skillSelected || ""]?.["1"]
}, [avatarSelected, avatarSkillTree, skillSelected])
const getImageSkill = useCallback((icon: string | undefined, status: StatusAddType | undefined, provider: string = "hakushin") => {
if (!icon) return
let urlPrefix = "https://homdgcat.wiki/images/skillicons/avatar/";
if (provider === "hakushin") {
urlPrefix = "https://api.hakush.in/hsr/UI/skillicons/"
}
if (icon.startsWith("SkillIcon")) {
if (provider === "homdgcat") {
return `${urlPrefix}${avatarSelected?.id}/${icon}`
} else if (provider === "hakushin") {
return `${urlPrefix}${icon.replace(".png", ".webp")}`
}
} else if (status && mappingStats[status.PropertyType]) {
return mappingStats[status.PropertyType].icon
}
else if (icon.startsWith("Icon")) {
return `https://api.hakush.in/hsr/UI/trace/${icon.replace(".png", ".webp")}`
}
}, [avatarSelected])
const getTraceBuffDisplay = useCallback((status: StatusAddType) => {
const dataDisplay = mappingStats[status.PropertyType]
if (!dataDisplay) return ""
if (dataDisplay.unit === "%") {
return `${(status.Value * 100).toFixed(1)}${dataDisplay.unit}`
}
if (dataDisplay.name === "SPD") {
return `${status.Value.toFixed(1)}${dataDisplay.unit}`
}
return `${status.Value.toFixed(0)}${dataDisplay.unit}`
}, [])
const dataLevelUpSkill = useMemo(() => {
const skillIds: number[] = skillInfo?.LevelUpSkillID || []
if (!avatarSelected || !avatarInfo || !avatarData) return
let result = Object.values(avatarInfo.Skills || {})?.filter((skill) => skillIds.includes(skill.Id))
if (avatarData.enhanced) {
result = Object.values(avatarInfo.Enhanced[avatarData.enhanced.toString()].Skills || {})?.filter((skill) => skillIds.includes(skill.Id))
}
if (result && result.length > 0) {
return {
isServant: false,
data: result,
servantData: null,
}
}
const resultServant = Object.entries(avatarInfo.Memosprite?.Skills || {})
?.filter(([skillId]) => skillIds.includes(Number(skillId)))
?.map(([skillId, skillData]) => ({
Id: Number(skillId),
...skillData,
}))
if (resultServant && resultServant.length > 0) {
return {
isServant: true,
data: resultServant,
servantData: avatarInfo.Memosprite,
}
}
return undefined
}, [skillInfo?.LevelUpSkillID, avatarSelected, avatarInfo, avatarData])
const handlerMaxAll = () => {
if (!avatarInfo || !avatarData || !avatarSkillTree) {
toast.error(transI18n("maxAllFailed"))
return
}
const newData = structuredClone(avatarData)
newData.data.skills = Object.values(avatarSkillTree).reduce((acc, dataPointEntry) => {
const firstEntry = Object.values(dataPointEntry)[0];
if (firstEntry) {
acc[firstEntry.PointID] = firstEntry.MaxLevel;
}
return acc;
}, {} as Record<string, number>)
toast.success(transI18n("maxAllSuccess"))
setAvatar(newData)
}
const handlerChangeStatusTrace = (status: boolean) => {
if (!avatarData || !skillInfo) return
const newData = structuredClone(avatarData)
newData.data.skills[skillInfo?.PointID] = status ? 1 : 0
if (!status && traceLink?.[avatarSelected?.baseType || ""]?.[skillSelected || ""]) {
traceLink[avatarSelected?.baseType || ""][skillSelected || ""].forEach((pointId) => {
if (avatarSkillTree?.[pointId]?.["1"]) {
newData.data.skills[avatarSkillTree?.[pointId]?.["1"].PointID] = 0
}
})
}
setAvatar(newData)
}
return (
<div className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="rounded-xl p-6 shadow-lg">
<h2 className="flex items-center gap-2 text-2xl font-bold mb-6 text-base-content">
<div className="w-2 h-6 bg-linear-to-b from-primary to-primary/50 rounded-full"></div>
{transI18n("skills")}
</h2>
<div className="flex flex-col items-center">
<button className="btn btn-success" onClick={handlerMaxAll}>{transI18n("maxAll")}</button>
{traceButtons && avatarInfo && (
<div className="grid col-span-4 relative w-full aspect-square">
<Image
src={`/skilltree/${avatarSelected?.baseType?.toUpperCase()}.webp`}
alt=""
width={312}
priority={true}
height={312}
style={{
filter: (theme === "winter" || theme === "cupcake") ? "invert(1)" : "none"
}}
className={`w-full h-full object-cover rounded-xl`}
/>
{traceButtons.map((btn, index) => {
if (!avatarInfo?.SkillTrees?.[btn.id]) {
return null
}
return (
<div
key={`${btn.id} + ${index}`}
id={btn.id}
className={`
absolute rounded-full border border-black
bg-no-repeat bg-contain
cursor-pointer transition-all duration-200 ease-in-out
shadow-[0_0_5px_white] flex justify-center items-center
hover:scale-110z-10
${btn.size === "small" ? "w-[6vw] h-[6vw] md:w-[2vw] md:h-[2vw] bg-white" : ""}
${btn.size === "medium" ? "w-[8vw] h-[8vw] md:w-[3vw] md:h-[3vw] bg-white" : ""}
${btn.size === "big" ? "w-[9vw] h-[9vw] md:w-[3.5vw] md:h-[3.5vw] bg-black" : ""}
${btn.size === "special" ? "w-[9vw] h-[9vw] md:w-[3.5vw] md:h-[3.5vw] bg-white" : ""}
${btn.size === "memory" ? "w-[9vw] h-[9vw] md:w-[3.5vw] md:h-[3.5vw] bg-black" : ""}
${skillSelected === btn.id ? "border-4 border-primary" : ""}
${!avatarData?.data.skills?.[avatarSkillTree?.[btn.id]?.["1"]?.PointID]
? "opacity-50 cursor-not-allowed"
: ""}
`}
onClick={() => {
setSkillSelected(btn.id === skillSelected ? null : btn.id)
}}
style={{
left: btn.left,
top: btn.top,
transform: "translate(-50%, -50%)",
}}
>
<Image
src={getImageSkill(avatarInfo?.SkillTrees?.[btn.id]?.["1"]?.Icon, avatarSkillTree?.[btn.id]?.["1"]?.StatusAddList[0]) || ""}
alt={btn.id.replaceAll("Point", "")}
priority={true}
width={124}
height={124}
style={{
filter: (btn.size !== "big" && btn.size !== "memory") ? "brightness(0%)" : ""
}}
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = getImageSkill(avatarInfo?.SkillTrees?.[btn.id]?.["1"]?.Icon, avatarSkillTree?.[btn.id]?.["1"]?.StatusAddList[0], "homdgcat") || "";
}}
/>
{(btn.size === "big" || btn.size === "memory") && (
<p className="
z-12 text-sm sm:text-xs lg:text-sm xl:text-base 2xl:text-2xl
font-bold text-center rounded-full absolute
translate-y-full mt-1
bg-base-300 px-1
left-1/2 transform -translate-x-1/2
">
{`${avatarData?.data.skills?.[avatarSkillTree?.[btn.id]?.["1"]?.PointID] || 0}/${avatarSkillTree?.[btn.id]?.["1"]?.MaxLevel}`}
</p>
)}
{btn.size === "special" && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "#4a6eff",
mixBlendMode: "screen",
opacity: 0.8,
borderRadius: "50%"
}}
/>
)}
{btn.size === "memory" && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "#9a89ff",
mixBlendMode: "screen",
opacity: 0.4,
borderRadius: "50%"
}}
/>
)}
{btn.size === "big" && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "#f5e4b0",
mixBlendMode: "screen",
opacity: 0.3,
borderRadius: "50%"
}}
/>
)}
</div>
)
})}
</div>
)}
</div>
</div>
<div className="bg-base-100 rounded-xl p-6 shadow-lg">
<h2 className="flex items-center gap-2 text-2xl font-bold mb-6 text-base-content">
<div className="w-2 h-6 bg-linear-to-b from-primary to-primary/50 rounded-full"></div>
{transI18n("details")}
</h2>
{skillSelected && avatarInfo?.SkillTrees && avatarData && (
<div>
{skillInfo?.MaxLevel && skillInfo?.MaxLevel > 1 ? (
<div>
<div className="font-bold text-success">{transI18n("level")}</div>
<div className="w-full max-w-xs">
<input type="range"
min={1}
max={skillInfo?.MaxLevel || 1}
value={avatarData?.data.skills?.[skillInfo?.PointID] || 1}
onChange={(e) => {
const newData = structuredClone(avatarData)
newData.data.skills[skillInfo?.PointID] = parseInt(e.target.value)
setAvatar(newData)
}}
className="range range-success"
step="1" />
<div className="flex justify-between px-2.5 mt-2 text-xs">
{Array.from({ length: skillInfo?.MaxLevel }, (_, index) => index + 1).map((index) => (
<span key={index}>{index}</span>
))}
</div>
</div>
</div>
) : skillInfo?.MaxLevel && skillInfo?.MaxLevel === 1 && traceButtons?.find((btn) => btn.id === skillSelected)?.size !== "big" ? (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={avatarData?.data.skills?.[skillInfo?.PointID] === 1}
className="toggle toggle-success"
onChange={(e) => {
if (traceButtons?.find((btn) => btn.id === skillSelected)?.size === "special") {
if (e.target.checked) {
const newData = structuredClone(avatarData)
newData.data.skills[skillInfo?.PointID] = 1
setAvatar(newData)
return
}
const newData = structuredClone(avatarData)
delete newData.data.skills[skillInfo?.PointID]
setAvatar(newData)
return
}
handlerChangeStatusTrace(e.target.checked)
}}
/>
<div className="font-bold text-success">
{avatarData?.data.skills?.[skillInfo?.PointID] === 1 ? transI18n("active") : transI18n("inactive")}
</div>
</div>
) : (
null
)}
{((skillInfo?.PointName && skillInfo?.PointDesc) ||
(skillInfo?.PointName && skillInfo?.StatusAddList.length > 0))
&& (
<div className="text-xl font-bold flex items-center gap-2 mt-2">
{skillInfo.PointName}
{skillInfo.StatusAddList.length > 0 && (
<div>
{skillInfo.StatusAddList.map((status, index) => (
<div key={index}>
<div className="text-xl font-bold">{getTraceBuffDisplay(status)}</div>
</div>
))}
</div>
)}
</div>
)}
{skillInfo?.PointDesc && (
<div
dangerouslySetInnerHTML={{
__html: replaceByParam(
skillInfo?.PointDesc || "",
skillInfo?.ParamList || []
)
}}
/>
)}
{skillInfo?.LevelUpSkillID
&& skillInfo?.LevelUpSkillID.length > 0
&& dataLevelUpSkill
&& (
<div className="mt-2 flex flex-col gap-2">
{dataLevelUpSkill?.data?.map((skill, index) => (
<div key={index}>
<div className="text-xl font-bold text-primary">
{transI18n(dataLevelUpSkill.isServant ? `${skill?.Type ? "severaltalent" : "servantskill"}` : `${skill?.Type ? skill?.Type.toLowerCase() : "talent"}`)}
{` (${transI18n(skill.Tag.toLowerCase())})`}
</div>
<div className="text-lg font-bold" dangerouslySetInnerHTML={{ __html: replaceByParam(skill.Name, []) }}>
</div>
<div
dangerouslySetInnerHTML={{
__html: replaceByParam(
skill.Desc || skill.SimpleDesc,
skill.Level[avatarData?.data.skills?.[skillInfo?.PointID]?.toString() || ""]?.ParamList || []
)
}}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import { PropsWithChildren, useContext } from "react";
import { ThemeContext } from "./themeContext";
export function ClientThemeWrapper({ children }: PropsWithChildren) {
const { theme } = useContext(ThemeContext);
return <div data-theme={theme} className="h-full">{children}</div>;
}

View File

@@ -0,0 +1,2 @@
export * from "./clientThemeWrapper"
export * from "./themeContext"

View File

@@ -0,0 +1,42 @@
"use client";
import { createContext, PropsWithChildren, useEffect } from "react";
import useLocaleStore from "@/stores/localeStore";
interface ThemeContextType {
theme?: string;
changeTheme?: (nextTheme: string | null) => void;
}
export const ThemeContext = createContext<ThemeContextType>({});
export const ThemeProvider = ({ children }: PropsWithChildren) => {
const { theme, setTheme } = useLocaleStore()
useEffect(() => {
if (typeof window !== "undefined") {
const storedTheme = localStorage.getItem("theme");
if (storedTheme) setTheme(storedTheme);
}
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const changeTheme = (nextTheme: string | null) => {
if (nextTheme) {
setTheme(nextTheme);
if (typeof window !== "undefined") {
localStorage.setItem("theme", nextTheme);
}
} else {
setTheme(theme === "winter" ? "night" : "winter");
if (typeof window !== "undefined") {
localStorage.setItem("theme", theme);
}
}
};
return (
<ThemeContext.Provider value={{ theme, changeTheme }}>
{children}
</ThemeContext.Provider>
);
};

165
src/constant/constant.ts Normal file
View File

@@ -0,0 +1,165 @@
export const listCurrentLanguage = {
ja: "JP",
ko: "KR",
en: "US",
vi: "VN",
zh: "CN"
};
export const listCurrentLanguageApi : Record<string, string> = {
ja: "jp",
ko: "kr",
en: "en",
vi: "en",
zh: "cn"
};
export const mappingStats = <Record<string, {name: string, icon: string, unit: string, baseStat: string}> > {
"HPDelta": {
name:"HP",
icon:"/icon/hp.webp",
unit: "",
baseStat: "HP"
},
"AttackDelta": {
name:"ATK",
icon:"/icon/attack.webp",
unit: "",
baseStat: "ATK"
},
"HPAddedRatio": {
name:"HP",
icon:"/icon/hp.webp",
unit: "%",
baseStat: "HP"
},
"AttackAddedRatio": {
name:"ATK",
icon:"/icon/attack.webp",
unit: "%",
baseStat: "ATK"
},
"DefenceDelta": {
name:"DEF",
icon:"/icon/defence.webp",
unit: "",
baseStat: "DEF"
},
"DefenceAddedRatio": {
name:"DEF",
icon:"/icon/defence.webp",
unit: "%",
baseStat: "DEF"
},
"SpeedAddedRatio": {
name:"SPD",
icon:"/icon/speed.webp",
unit: "%",
baseStat: "SPD"
},
"BaseSpeed": {
name:"SPD",
icon:"/icon/speed.webp",
unit: "",
baseStat: "SPD"
},
"CriticalChanceBase": {
name:"CRIT Rate",
icon:"/icon/crit-rate.webp",
unit: "%",
baseStat: "CRITRate"
},
"CriticalDamageBase": {
name:"CRIT DMG",
icon:"/icon/crit-damage.webp",
unit: "%",
baseStat: "CRITDmg"
},
"HealRatioBase": {
name:"Outgoing Healing Boost",
icon:"/icon/healing-boost.webp",
unit: "%",
baseStat: "HealBoost"
},
"StatusProbabilityBase": {
name:"Effect Hit Rate",
icon:"/icon/effect-hit-rate.webp",
unit: "%",
baseStat: "EffectHitRate"
},
"StatusResistanceBase": {
name:"Effect RES",
icon:"/icon/effect-res.webp",
unit: "%",
baseStat: "EffectRES"
},
"BreakDamageAddedRatioBase": {
name:"Break Effect",
icon:"/icon/break-effect.webp",
unit: "%",
baseStat: "BreakEffect"
},
"SpeedDelta": {
name:"SPD",
icon:"/icon/speed.webp",
unit: "",
baseStat: "SPD"
},
"PhysicalAddedRatio": {
name:"Physical DMG Boost",
icon:"/icon/physical-add.webp",
unit: "%",
baseStat: "PhysicalAdd"
},
"FireAddedRatio": {
name:"Fire DMG Boost",
icon:"/icon/fire-add.webp",
unit: "%",
baseStat: "FireAdd"
},
"IceAddedRatio": {
name:"Ice DMG Boost",
icon:"/icon/ice-add.webp",
unit: "%",
baseStat: "IceAdd"
},
"ThunderAddedRatio": {
name:"Thunder DMG Boost",
icon:"/icon/thunder-add.webp",
unit: "%",
baseStat: "ThunderAdd"
},
"WindAddedRatio": {
name:"Wind DMG Boost",
icon:"/icon/wind-add.webp",
unit: "%",
baseStat: "WindAdd"
},
"QuantumAddedRatio": {
name:"Quantum DMG Boost",
icon:"/icon/quantum-add.webp",
unit: "%",
baseStat: "QuantumAdd"
},
"ImaginaryAddedRatio": {
name:"Imaginary DMG Boost",
icon:"/icon/imaginary-add.webp",
unit: "%",
baseStat: "ImaginaryAdd"
},
"SPRatioBase": {
name:"Energy Regeneration Rate",
icon:"/icon/energy-rate.webp",
unit: "%",
baseStat: "EnergyRate"
}
}
export const ratioStats = [
"HPAddedRatio",
"AttackAddedRatio",
"DefenceAddedRatio",
"SpeedAddedRatio",
]

File diff suppressed because it is too large Load Diff

100
src/helper/calcData.ts Normal file
View File

@@ -0,0 +1,100 @@
import { mappingStats, ratioStats } from "@/constant/constant"
import { AffixDetail } from "@/types"
export function calcPromotion(level: number) {
if (level < 20) {
return 0
}
if (level < 30) {
return 1
}
if (level < 40) {
return 2
}
if (level < 50) {
return 3
}
if (level < 60) {
return 4
}
if (level < 70) {
return 5
}
return 6
}
export function calcRarity(rarity: string) {
if (rarity.includes("5")) {
return 5
}
if (rarity.includes("4")) {
return 4
}
if (rarity.includes("3")) {
return 3
}
return 1
}
export function calcMainAffixBonus(affix?: AffixDetail, level?: number) {
if (!affix || typeof level !== "number") return "0"
const value = affix.base + affix.step * level;
if (mappingStats?.[affix.property].unit === "%") {
return (value * 100).toFixed(1);
}
if (mappingStats?.[affix.property].name === "SPD") {
return value.toFixed(1);
}
return value.toFixed(0);
}
export const calcAffixBonus = (affix?: AffixDetail, stepCount?: number, rollCount?: number) => {
if (!affix || typeof stepCount !== "number" || typeof rollCount !== "number") return "0"
if (mappingStats?.[affix.property].unit === "%") {
return ((affix.base * rollCount + affix.step * stepCount) * 100).toFixed(1);
}
if (mappingStats?.[affix.property].name === "SPD") {
return (affix.base * rollCount + affix.step * stepCount).toFixed(1);
}
return (affix.base * rollCount + affix.step * stepCount).toFixed(0);
}
export const calcBaseStat = (baseStat: number, stepStat: number, roundFixed: number, level: number) => {
const promotionStat = baseStat + stepStat * (level-1);
return promotionStat.toFixed(roundFixed);
}
export const calcBaseStatRaw = (baseStat?: number, stepStat?: number, level?: number) => {
if (typeof baseStat !== "number" || typeof stepStat !== "number" || typeof level !== "number") return 0
return baseStat + stepStat * (level-1);
}
export const calcSubAffixBonusRaw = (affix?: AffixDetail, stepCount?: number, rollCount?: number, baseStat?: number) => {
if (!affix || typeof stepCount !== "number" || typeof rollCount !== "number" || typeof baseStat !== "number") return 0
if (ratioStats.includes(affix.property)) {
return (affix.base * rollCount + affix.step * stepCount) * baseStat;
}
return affix.base * rollCount + affix.step * stepCount;
}
export const calcMainAffixBonusRaw = (affix?: AffixDetail, level?: number, baseStat?: number) => {
if (!affix || typeof level !== "number" || typeof baseStat !== "number") return 0
const value = affix.base + affix.step * level;
if (ratioStats.includes(affix.property)) {
return baseStat * value
}
return value
}
export const calcBonusStatRaw = (affix?: string, baseStat?: number, bonusValue?: number) => {
if (!affix || typeof baseStat !== "number" || typeof bonusValue !== "number") return 0
if (ratioStats.includes(affix)) {
return baseStat * bonusValue
}
return bonusValue
}

100
src/helper/connect.ts Normal file
View File

@@ -0,0 +1,100 @@
"use client"
import { SendDataThroughProxy, SendDataToServer } from "@/lib/api/api"
import useConnectStore from "@/stores/connectStore"
import useUserDataStore from "@/stores/userDataStore"
import { converterToFreeSRJson } from "./converterToFreeSRJson"
import { psResponseSchema } from "@/zod"
import useGlobalStore from "@/stores/globalStore"
export const connectToPS = async (): Promise<{ success: boolean, message: string }> => {
const {
connectionType,
privateType,
serverUrl,
username,
password
} = useConnectStore.getState()
const {setExtraData, setIsConnectPS} = useGlobalStore.getState()
let urlQuery = serverUrl
if (!urlQuery.startsWith("http://") && !urlQuery.startsWith("https://")) {
urlQuery = `http://${urlQuery}`
}
if (connectionType === "FireflyGo") {
urlQuery = "http://localhost:21000/sync"
} else if (connectionType === "Other" && privateType === "Server") {
const response = await SendDataThroughProxy({data: {username, password, serverUrl, data: null, method: "POST"}})
if (response instanceof Error) {
return { success: false, message: response.message }
} else if (response.error) {
return { success: false, message: response.error }
} else {
const parsed = psResponseSchema.safeParse(response.data)
if (!parsed.success) {
return { success: false, message: "Invalid response schema" }
}
return { success: true, message: "" }
}
}
const response = await SendDataToServer(username, password, urlQuery, null)
if (typeof response === "string") {
setIsConnectPS(false)
return { success: false, message: response }
} else if (response.status != 200) {
setIsConnectPS(false)
return { success: false, message: response.message }
} else {
setIsConnectPS(true)
setExtraData(response?.extra_data)
return { success: true, message: "" }
}
}
export const syncDataToPS = async (): Promise<{ success: boolean, message: string }> => {
const {
connectionType,
privateType,
serverUrl,
username,
password
} = useConnectStore.getState()
const {extraData, setIsConnectPS, setExtraData} = useGlobalStore.getState()
const {avatars, battle_type, moc_config, pf_config, as_config, ce_config, peak_config} = useUserDataStore.getState()
const data = converterToFreeSRJson(avatars, battle_type, moc_config, pf_config, as_config, ce_config, peak_config)
let urlQuery = serverUrl
if (!urlQuery.startsWith("http://") && !urlQuery.startsWith("https://")) {
urlQuery = `http://${urlQuery}`
}
if (connectionType === "FireflyGo") {
urlQuery = "http://localhost:21000/sync"
} else if (connectionType === "Other" && privateType === "Server") {
const response = await SendDataThroughProxy({data: {username, password, serverUrl, data, method: "POST"}})
if (response instanceof Error) {
return { success: false, message: response.message }
} else if (response.error) {
return { success: false, message: response.error }
} else {
const parsed = psResponseSchema.safeParse(response.data)
if (!parsed.success) {
return { success: false, message: "Invalid response schema" }
}
return { success: true, message: "" }
}
}
const response = await SendDataToServer(username, password, urlQuery, data, extraData)
if (typeof response === "string") {
setIsConnectPS(false)
return { success: false, message: response }
} else if (response.status != 200) {
setIsConnectPS(false)
return { success: false, message: response.message }
} else {
setIsConnectPS(true)
setExtraData(response?.extra_data)
return { success: true, message: "" }
}
}

142
src/helper/convertData.ts Normal file
View File

@@ -0,0 +1,142 @@
import { CharacterBasic, CharacterBasicRaw, EventBasic, EventBasicRaw, LightConeBasic, LightConeBasicRaw, MonsterBasic, MonsterBasicRaw, RelicBasic, RelicBasicEffect, RelicBasicRaw } from "@/types";
export function convertRelicSet(id: string, item: RelicBasicRaw): RelicBasic {
const lang = new Map<string, string>([
['en', item.en],
['kr', item.kr],
['cn', item.cn],
['jp', item.jp]
]);
const setRelic = new Map<string, RelicBasicEffect>();
Object.entries(item.set).forEach(([key, value]) => {
setRelic.set(key, {
ParamList: value.ParamList,
lang: new Map<string, string>([
['en', value.en],
['kr', value.kr],
['cn', value.cn],
['jp', value.jp]
])
});
});
const result: RelicBasic = {
icon: item.icon,
lang: lang,
id: id,
set: setRelic
};
return result;
}
export function convertLightcone(id: string, item: LightConeBasicRaw): LightConeBasic {
const lang = new Map<string, string>([
['en', item.en],
['kr', item.kr],
['cn', item.cn],
['jp', item.jp]
]);
const result: LightConeBasic = {
rank: item.rank,
baseType: item.baseType,
desc: item.desc,
lang: lang,
id: id
};
return result;
}
export function convertAvatar(id: string, item: CharacterBasicRaw): CharacterBasic {
const lang = new Map<string, string>([
['en', item.en],
['kr', item.kr],
['cn', item.cn],
['jp', item.jp]
]);
let text = ""
if (Number(id) % 2 === 0 && Number(id) > 8000) {
text = `Female ${item.damageType} MC`
} else if (Number(id) > 8000) {
text = `Male ${item.damageType} MC`
}
if (text !== "") {
lang.set("en", text)
lang.set("kr", text)
lang.set("cn", text)
lang.set("jp", text)
}
const result: CharacterBasic = {
release: item.release,
icon: item.icon,
rank: item.rank,
baseType: item.baseType,
damageType: item.damageType,
desc: item.desc,
lang: lang,
id: id
};
return result;
}
export function convertEvent(id: string, item: EventBasicRaw): EventBasic {
const lang = new Map<string, string>([
['en', item.en],
['kr', item.kr],
['cn', item.cn],
['jp', item.jp]
]);
const result: EventBasic = {
lang: lang,
id: id,
begin: item.begin,
end: item.end,
live_begin: item.live_begin,
live_end: item.live_end,
param: item.param,
};
return result;
}
export function convertMonster(id: string, item: MonsterBasicRaw): MonsterBasic {
const lang = new Map<string, string>([
['en', item.en],
['kr', item.kr],
['cn', item.cn],
['jp', item.jp]
]);
const result: MonsterBasic = {
id: id,
rank: item.rank,
camp: item.camp,
icon: item.icon,
child: item.child,
weak: item.weak,
desc: item.desc,
lang: lang
};
return result;
}
export function convertToRoman(num: number): string {
const roman: [number, string][] = [
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'],
[100, 'C'], [90, 'XC'], [50, 'L'], [40, 'XL'],
[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']
];
let result = '';
for (const [val, sym] of roman) {
while (num >= val) {
result += sym;
num -= val;
}
}
return result;
}

View File

@@ -0,0 +1,101 @@
import { AvatarEnkaDetail, AvatarProfileStore, AvatarStore, CharacterDetail, FreeSRJson, RelicStore } from "@/types";
function safeNumber(val: string | number | null, fallback = 0): number {
if (!val) return fallback;
const num = Number(val);
return Number.isFinite(num) && num !== 0 ? num : fallback;
}
export function converterToAvatarStore(data: Record<string, CharacterDetail>): { [key: string]: AvatarStore } {
return Object.fromEntries(
Object.entries(data).map(([key, value]) => [
key,
{
owner_uid: 0,
avatar_id: Number(key),
data: {
rank: 0,
skills: Object.values(value.SkillTrees).reduce((acc, dataPointEntry) => {
const firstEntry = Object.values(dataPointEntry)[0];
if (firstEntry) {
acc[firstEntry.PointID] = firstEntry.MaxLevel;
}
return acc;
}, {} as Record<string, number>)
},
level: 80,
promotion: 6,
techniques: [],
sp_max: safeNumber(value.SPNeed, 100),
can_change_sp: safeNumber(value.SPNeed) !== 0,
sp_value: Math.ceil(safeNumber(value.SPNeed) / 2),
profileSelect: 0,
enhanced: "",
profileList: [{
profile_name: "Default",
lightcone: null,
relics: {} as Record<string, RelicStore>
} as AvatarProfileStore]
}
])
) as { [key: string]: AvatarStore }
}
export function converterOneEnkaDataToAvatarStore(data: AvatarEnkaDetail, count: number): AvatarProfileStore | null {
if (!data.equipment && (!data.relicList || data.relicList.length === 0)) return null
const profile: AvatarProfileStore = {
profile_name: `Enka Profile ${count}`,
lightcone: (data.equipment && data.equipment.tid) ? {
level: data.equipment?.level ?? 0,
item_id: data.equipment?.tid ?? 0,
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], {
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_affix_id: subAffix.affixId,
count: subAffix.cnt,
step: subAffix.step ?? 0
}))
}]))
}
return profile
}
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)
if (!lightcone && (!relics || relics.length === 0)) return null
const relicsMap = {} as Record<string, RelicStore>
relics.forEach((relic) => {
relicsMap[relic.relic_id.toString()[relic.relic_id.toString().length - 1]] = {
level: relic.level,
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_affix_id: subAffix.sub_affix_id,
count: subAffix.count,
step: subAffix.step ?? 0
}))
}
})
const profile: AvatarProfileStore = {
profile_name: `FreeSR Profile ${count}`,
lightcone: (lightcone && lightcone.item_id) ? {
level: lightcone?.level ?? 0,
item_id: lightcone?.item_id ?? 0,
rank: lightcone?.rank ?? 0,
promotion: lightcone?.promotion ?? 0,
} : null,
relics: relicsMap
}
return profile
}

View File

@@ -0,0 +1,133 @@
import { ASConfigStore, AvatarJson, AvatarStore, BattleConfigJson, CEConfigStore, FreeSRJson, LightconeJson, MOCConfigStore, PEAKConfigStore, PFConfigStore, RelicJson } from "@/types";
export function converterToFreeSRJson(
avatars: Record<string, AvatarStore>,
battle_type: string,
moc_config: MOCConfigStore,
pf_config: PFConfigStore,
as_config: ASConfigStore,
ce_config: CEConfigStore,
peak_config: PEAKConfigStore,
): FreeSRJson {
const lightcones: LightconeJson[] = []
const relics: RelicJson[] = []
let battleJson: BattleConfigJson
if (battle_type === "MOC") {
battleJson = {
battle_type: battle_type,
blessings: moc_config.blessings,
custom_stats: [],
cycle_count: moc_config.cycle_count,
stage_id: moc_config.stage_id,
path_resonance_id: 0,
monsters: moc_config.monsters,
}
} else if (battle_type === "PF") {
battleJson = {
battle_type: battle_type,
blessings: pf_config.blessings,
custom_stats: [],
cycle_count: pf_config.cycle_count,
stage_id: pf_config.stage_id,
path_resonance_id: 0,
monsters: pf_config.monsters,
}
} else if (battle_type === "AS") {
battleJson = {
battle_type: battle_type,
blessings: as_config.blessings,
custom_stats: [],
cycle_count: as_config.cycle_count,
stage_id: as_config.stage_id,
path_resonance_id: 0,
monsters: as_config.monsters,
}
} else if (battle_type === "CE") {
battleJson = {
battle_type: battle_type,
blessings: ce_config.blessings,
custom_stats: [],
cycle_count: ce_config.cycle_count,
stage_id: ce_config.stage_id,
path_resonance_id: 0,
monsters: ce_config.monsters,
}
} else if (battle_type === "PEAK") {
battleJson = {
battle_type: battle_type,
blessings: peak_config.blessings,
custom_stats: [],
cycle_count: peak_config.cycle_count,
stage_id: peak_config.stage_id,
path_resonance_id: 0,
monsters: peak_config.monsters,
}
} else {
battleJson = {
battle_type: battle_type,
blessings: [],
custom_stats: [],
cycle_count: 0,
stage_id: 0,
path_resonance_id: 0,
monsters: [],
}
}
const avatarsJson: { [key: string]: AvatarJson } = {}
let internalUidLightcone = 0
let internalUidRelic = 0
Object.entries(avatars).forEach(([avatarId, avatar]) => {
avatarsJson[avatarId] = {
owner_uid: Number(avatar.owner_uid || 0),
avatar_id: Number(avatar.avatar_id || 0),
data: avatar.data,
level: Number(avatar.level || 0),
promotion: Number(avatar.promotion || 0),
techniques: avatar.techniques,
sp_value: Number(avatar.sp_value || 0),
sp_max: Number(avatar.sp_max || 0),
}
const currentProfile = avatar.profileList[avatar.profileSelect]
if (currentProfile.lightcone && currentProfile.lightcone.item_id !== 0) {
const newLightcone: LightconeJson = {
level: Number(currentProfile.lightcone.level || 0),
item_id: Number(currentProfile.lightcone.item_id || 0),
rank: Number(currentProfile.lightcone.rank || 0),
promotion: Number(currentProfile.lightcone.promotion || 0),
internal_uid: internalUidLightcone,
equip_avatar: Number(avatar.avatar_id || 0),
}
internalUidLightcone++
lightcones.push(newLightcone)
}
if (currentProfile.relics) {
["1", "2", "3", "4", "5", "6"].forEach(slot => {
const relic = currentProfile.relics[slot]
if (relic && relic.relic_id !== 0) {
const newRelic: RelicJson = {
level: Number(relic.level || 0),
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,
internal_uid: internalUidRelic,
equip_avatar: Number(avatar.avatar_id || 0),
}
internalUidRelic++
relics.push(newRelic)
}
})
}
})
return {
lightcones,
relics,
avatars: avatarsJson,
battle_config: battleJson,
}
}

45
src/helper/getName.ts Normal file
View File

@@ -0,0 +1,45 @@
import { listCurrentLanguage } from "@/constant/constant";
import { CharacterBasic, EventBasic, LightConeBasic, MonsterBasic } from "@/types";
export function getNameChar(locale: string, data: CharacterBasic | undefined): string {
if (!data) {
return ""
}
if (!listCurrentLanguage.hasOwnProperty(locale)) {
return ""
}
let text = data.lang.get(listCurrentLanguage[locale as keyof typeof listCurrentLanguage].toLowerCase()) ?? "";
if (!text) {
text = data.lang.get("en") ?? "";
}
if (Number(data.id) % 2 === 0 && Number(data.id) > 8000) {
text = `Female ${data.damageType} MC`
} else if (Number(data.id) > 8000) {
text = `Male ${data.damageType} MC`
}
return text
}
export function getLocaleName(locale: string, data: LightConeBasic | EventBasic | MonsterBasic | undefined): string {
if (!data) {
return ""
}
if (!listCurrentLanguage.hasOwnProperty(locale)) {
return ""
}
let text = data.lang.get(listCurrentLanguage[locale as keyof typeof listCurrentLanguage].toLowerCase()) ?? "";
if (!text) {
text = data.lang.get("en") ?? "";
}
return text
}
export function parseRuby(text: string): string {
const rubyRegex = /\{RUBY_B#(.*?)\}(.*?)\{RUBY_E#\}/gs;
return text.replace(rubyRegex, (_match, furigana, kanji) => {
return `<ruby>${kanji}<rt>${furigana}</rt></ruby>`;
});
}

View File

@@ -0,0 +1,23 @@
import useAvatarStore from "@/stores/avatarStore";
export function getSkillTree(enhanced: string) {
const { avatarSelected, mapAvatarInfo } = useAvatarStore.getState()
if (!avatarSelected) return null;
if (enhanced != "") return Object.values(mapAvatarInfo[avatarSelected.id || ""]?.Enhanced[enhanced].SkillTrees || {}).reduce((acc, dataPointEntry) => {
const firstEntry = Object.values(dataPointEntry)[0];
if (firstEntry) {
acc[firstEntry.PointID] = firstEntry.MaxLevel;
}
return acc;
}, {} as Record<string, number>)
return Object.values(mapAvatarInfo[avatarSelected.id || ""]?.SkillTrees).reduce((acc, dataPointEntry) => {
const firstEntry = Object.values(dataPointEntry)[0];
if (firstEntry) {
acc[firstEntry.PointID] = firstEntry.MaxLevel;
}
return acc;
}, {} as Record<string, number>);
}

8
src/helper/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from "./getName"
export * from "./replaceByParam"
export * from "./converterToAvatarStore"
export * from "./calcData"
export * from "./random"
export * from "./json"
export * from "./convertData"
export * from "./connect"

15
src/helper/json.ts Normal file
View File

@@ -0,0 +1,15 @@
// 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)
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)
}

27
src/helper/random.ts Normal file
View File

@@ -0,0 +1,27 @@
export function randomPartition(sum: number, parts: number): number[] {
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);
let diff = sum - result.reduce((a, b) => a + b, 0);
while (diff !== 0) {
for (let i = 0; i < result.length && diff !== 0; i++) {
if (diff > 0) {
result[i]++;
diff--;
} else if (result[i] > 1) {
result[i]--;
diff++;
}
}
}
return result;
}
export function randomStep(x: number): number {
let total = 0;
for (let i = 0; i < x; i++) {
total += Math.floor(Math.random() * 3);
}
return total;
}

View File

@@ -0,0 +1,52 @@
export function replaceByParam(desc: string, params: number[]): string {
function formatParam(
indexStr: string,
format: string,
floatDigits: string | undefined,
percent: string | undefined
): string {
const i: number = parseInt(indexStr, 10) - 1;
const value: number | undefined = params[i];
if (value === undefined) return "";
if (format.startsWith("f")) {
const digits: number = parseInt(floatDigits || "1", 10);
const num: number = percent ? value * 100 : value;
return `${num.toFixed(digits)}${percent ? "%" : ""}`;
}
if (format === "i") {
return percent ? `${(value * 100).toFixed(0)}%` : `${Math.round(value)}`;
}
return `${value}`;
}
const desc1 = desc.replace(/<color=#[0-9a-fA-F]{8}>(.*?)<\/color>/g, (match: string, inner: string): string => {
const colorCode: string = match.match(/#[0-9a-fA-F]{8}/)?.[0] ?? "#ffffff";
const processed: string = inner
.replace(/#(\d+)\[(f(\d+)|i)\](%)?/g, (
_: string,
index: string,
format: string,
floatDigits: string | undefined,
percent: string | undefined
): string => formatParam(index, format, floatDigits, percent))
.replace(/<unbreak>(.*?)<\/unbreak>/g, "$1");
return `<span style="color:${colorCode}">${processed}</span>`;
});
const desc2 = desc1.replace(/<unbreak>#(\d+)\[(f(\d+)|i)\](%)?<\/unbreak>/g, (
_: string,
index: string,
format: string,
floatDigits: string | undefined,
percent: string | undefined
): string => formatParam(index, format, floatDigits, percent));
const desc3 = desc2.replace(/<unbreak>(\d+)<\/unbreak>/g, (_: string, number: string): string => number);
const desc4 = desc3.replaceAll("\\n", "<br></br>");
return desc4;
}

10
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from "./useFetchConfigData";
export * from "./useChangeTheme";
export * from "./useFetchAvatarData";
export * from "./useFetchLightconeData";
export * from "./useFetchRelicData";
export * from "./useFetchMonsterData";
export * from "./useFetchPFData";
export * from "./useFetchMOCData";
export * from "./useFetchASData";
export * from "./useFetchPEAKData";

View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { ThemeContext } from "@/components/themeController";
export const useChangeTheme = () => useContext(ThemeContext);

View File

@@ -0,0 +1,66 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { fetchASByIdsNative, getASEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
import { toast } from 'react-toastify'
import useEventStore from '@/stores/eventStore'
import { EventStageDetail } from '@/types'
export const useFetchASData = () => {
const { setASEvent, setMapASInfo } = useEventStore()
const { locale } = useLocaleStore()
const { data: dataAS, error: errorAS } = useQuery({
queryKey: ['asData'],
queryFn: getASEventListApi,
select: (data) => data.sort((a, b) => Number(b.id) - Number(a.id)),
staleTime: 1000 * 60 * 5,
})
const { data: dataASInfo, error: errorASInfo } = useQuery({
queryKey: ['asInfoData', locale],
queryFn: () =>
fetchASByIdsNative(
dataAS!.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
),
staleTime: 1000 * 60 * 5,
enabled: !!dataAS,
select: (data) => {
const newData = { ...data }
for (const key in newData) {
for (const item of newData[key].Level) {
item.EventIDList1 = item.EventIDList1.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
item.EventIDList2 = item.EventIDList2.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
}
}
return newData
},
});
useEffect(() => {
if (dataAS && !errorAS) {
setASEvent(dataAS)
} else if (errorAS) {
toast.error("Failed to load AS data")
}
}, [dataAS, errorAS, setASEvent])
useEffect(() => {
if (dataASInfo && !errorASInfo) {
setMapASInfo(dataASInfo)
} else if (errorASInfo) {
toast.error("Failed to load AS info data")
}
}, [dataASInfo, errorASInfo, setMapASInfo])
}

View File

@@ -0,0 +1,74 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { fetchCharactersByIdsNative, getCharacterListApi } from '@/lib/api'
import { useEffect } from 'react'
import { toast } from 'react-toastify'
import useAvatarStore from '@/stores/avatarStore'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
import useUserDataStore from '@/stores/userDataStore'
import { converterToAvatarStore } from '@/helper'
export const useFetchAvatarData = () => {
const { setAvatar, avatars } = useUserDataStore()
const { setListAvatar, setAllMapAvatarInfo, mapAvatarInfo, setAvatarSelected, avatarSelected } = useAvatarStore()
const { locale } = useLocaleStore()
const { data: dataAvatar, error: errorAvatar } = useQuery({
queryKey: ['avatarData'],
queryFn: getCharacterListApi,
select: (data) => data.sort((a, b) => {
const aHasRelease = typeof a.release === 'number';
const bHasRelease = typeof b.release === 'number';
if (!aHasRelease && !bHasRelease) return 0;
if (!aHasRelease) return -1;
if (!bHasRelease) return 1;
return b.release! - a.release!;
}),
staleTime: 1000 * 60 * 5,
})
useEffect(() => {
const listAvatarId = Object.keys(avatars)
const listAvatarNotExist = Object.entries(mapAvatarInfo).filter(([avatarId]) => !listAvatarId.includes(avatarId))
const avatarStore = converterToAvatarStore(Object.fromEntries(listAvatarNotExist))
if (Object.keys(avatarStore).length === 0) return
for (const avatar of Object.values(avatarStore)) {
setAvatar(avatar)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapAvatarInfo])
const { data: dataAvatarInfo, error: errorAvatarInfo } = useQuery({
queryKey: ['avatarInfoData', locale],
queryFn: () =>
fetchCharactersByIdsNative(
dataAvatar!.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
),
staleTime: 1000 * 60 * 5,
enabled: !!dataAvatar,
});
useEffect(() => {
if (dataAvatar && !errorAvatar) {
setListAvatar(dataAvatar)
if (!avatarSelected) {
setAvatarSelected(dataAvatar[0])
}
} else if (errorAvatar) {
toast.error("Failed to load avatar data")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataAvatar, errorAvatar])
useEffect(() => {
if (dataAvatarInfo && !errorAvatarInfo) {
setAllMapAvatarInfo(dataAvatarInfo)
} else if (errorAvatarInfo) {
toast.error("Failed to load avatar info data")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataAvatarInfo, errorAvatarInfo])
}

View File

@@ -0,0 +1,37 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { getConfigMazeApi, getMainAffixApi, getSubAffixApi } from '@/lib/api'
import useAffixStore from '@/stores/affixStore'
import useMazeStore from '@/stores/mazeStore'
import { useEffect } from 'react'
import { toast } from 'react-toastify'
export const useFetchConfigData = () => {
const { setMapMainAffix, setMapSubAffix } = useAffixStore()
const { setAllMazeData } = useMazeStore()
const { data, error } = useQuery({
queryKey: ['initialConfigData'],
queryFn: async () => {
const [maze, main, sub] = await Promise.all([
getConfigMazeApi(),
getMainAffixApi(),
getSubAffixApi(),
])
return { maze, main, sub }
},
staleTime: 1000 * 60 * 5,
})
useEffect(() => {
if (data && !error) {
setAllMazeData(data.maze)
setMapMainAffix(data.main)
setMapSubAffix(data.sub)
}
else if (error) {
toast.error("Failed to load initial config data")
}
}, [data, error, setAllMazeData, setMapMainAffix, setMapSubAffix])
}

View File

@@ -0,0 +1,47 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { fetchLightconesByIdsNative, getLightconeListApi } from '@/lib/api'
import { useEffect } from 'react'
import useLightconeStore from '@/stores/lightconeStore'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
import { toast } from 'react-toastify'
export const useFetchLightconeData = () => {
const { setListLightcone, setAllMapLightconeInfo } = useLightconeStore()
const { locale } = useLocaleStore()
const { data: dataLightcone, error: errorLightcone } = useQuery({
queryKey: ['lightconeData'],
queryFn: getLightconeListApi,
select: (data) => data.sort((a, b) => Number(b.id) - Number(a.id)),
staleTime: 1000 * 60 * 5,
})
const { data: dataLightconeInfo, error: errorLightconeInfo } = useQuery({
queryKey: ['lightconeInfoData', locale],
queryFn: () =>
fetchLightconesByIdsNative(
dataLightcone!.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
),
staleTime: 1000 * 60 * 5,
enabled: !!dataLightcone,
});
useEffect(() => {
if (dataLightcone && !errorLightcone) {
setListLightcone(dataLightcone)
} else if (errorLightcone) {
toast.error("Failed to load lightcone data")
}
}, [dataLightcone, errorLightcone, setListLightcone])
useEffect(() => {
if (dataLightconeInfo && !errorLightconeInfo) {
setAllMapLightconeInfo(dataLightconeInfo)
} else if (errorLightconeInfo) {
toast.error("Failed to load lightcone info data")
}
}, [dataLightconeInfo, errorLightconeInfo, setAllMapLightconeInfo])
}

View File

@@ -0,0 +1,68 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { fetchMOCByIdsNative, getMOCEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
import { toast } from 'react-toastify'
import useEventStore from '@/stores/eventStore'
import { EventStageDetail, MocDetail } from '@/types'
export const useFetchMOCData = () => {
const { setMOCEvent, setMapMOCInfo } = useEventStore()
const { locale } = useLocaleStore()
const { data: dataMOC, error: errorMOC } = useQuery({
queryKey: ['mocData'],
queryFn: getMOCEventListApi,
select: (data) => data.sort((a, b) => Number(b.id) - Number(a.id)),
staleTime: 1000 * 60 * 5,
})
const { data: dataMOCInfo, error: errorMOCInfo } = useQuery({
queryKey: ['mocInfoData', locale],
queryFn: async () => {
const result = await fetchMOCByIdsNative(
dataMOC!.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
);
return result;
},
staleTime: 1000 * 60 * 5,
select: (data) => {
const newData = { ...data }
for (const key in newData) {
for (const item of newData[key]) {
item.EventIDList1 = item.EventIDList1.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
item.EventIDList2 = item.EventIDList2.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
}
}
return newData
},
enabled: !!dataMOC,
});
useEffect(() => {
if (dataMOC && !errorMOC) {
setMOCEvent(dataMOC)
} else if (errorMOC) {
toast.error("Failed to load MOC data")
}
}, [dataMOC, errorMOC, setMOCEvent])
useEffect(() => {
if (dataMOCInfo && !errorMOCInfo) {
setMapMOCInfo(dataMOCInfo as Record<string, MocDetail[]>)
} else if (errorMOCInfo) {
toast.error("Failed to load MOC info data")
}
}, [dataMOCInfo, errorMOCInfo, setMapMOCInfo])
}

View File

@@ -0,0 +1,60 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { getMonsterValueApi, getMonsterListApi, fetchMonsterByIdsNative } from '@/lib/api'
import { useEffect } from 'react'
import { toast } from 'react-toastify'
import useMonsterStore from '@/stores/monsterStore'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
export const useFetchMonsterData = () => {
const { setAllMapMonster, setListMonster, setAllMapMonsterValue, setAllMapMonsterInfo } = useMonsterStore()
const { locale } = useLocaleStore()
const { data: dataMonster, error: errorMonster } = useQuery({
queryKey: ['monsterData'],
queryFn: getMonsterListApi,
staleTime: 1000 * 60 * 5,
})
const { data: dataMonsterValue, error: errorMonsterValue } = useQuery({
queryKey: ['monsterValueData'],
queryFn: getMonsterValueApi,
staleTime: 1000 * 60 * 5,
})
const { data: dataMonsterDetail, error: errorMonsterDetail } = useQuery({
queryKey: ['monsterDetailData', locale],
queryFn: () =>
fetchMonsterByIdsNative(
dataMonster!.list.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
),
staleTime: 1000 * 60 * 5,
enabled: !!dataMonster,
});
useEffect(() => {
if (dataMonster && !errorMonster) {
setListMonster(dataMonster.list.sort((a, b) => Number(b.id) - Number(a.id)))
setAllMapMonster(dataMonster.map)
} else if (errorMonster) {
toast.error("Failed to load monster data")
}
}, [dataMonster, errorMonster, setAllMapMonster, setListMonster])
useEffect(() => {
if (dataMonsterValue && !errorMonsterValue) {
setAllMapMonsterValue(dataMonsterValue)
} else if (errorMonsterValue) {
toast.error("Failed to load monster value data")
}
}, [dataMonsterValue, errorMonsterValue, setAllMapMonsterValue])
useEffect(() => {
if (dataMonsterDetail && !errorMonsterDetail) {
setAllMapMonsterInfo(dataMonsterDetail)
} else if (errorMonsterDetail) {
toast.error("Failed to load monster detail data")
}
}, [dataMonsterDetail, errorMonsterDetail, setAllMapMonsterInfo])
}

View File

@@ -0,0 +1,71 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { fetchPeakByIdsNative, getPEAKEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
import { toast } from 'react-toastify'
import useEventStore from '@/stores/eventStore'
import { EventStageDetail, PeakDetail } from '@/types'
export const useFetchPEAKData = () => {
const { setPEAKEvent, setMapPEAKInfo } = useEventStore()
const { locale } = useLocaleStore()
const { data: dataPEAK, error: errorPEAK } = useQuery({
queryKey: ['peakData'],
queryFn: getPEAKEventListApi,
select: (data) => data.sort((a, b) => Number(b.id) - Number(a.id)),
staleTime: 1000 * 60 * 5,
})
const { data: dataPEAKInfo, error: errorPEAKInfo } = useQuery({
queryKey: ['peakInfoData', locale],
queryFn: () =>
fetchPeakByIdsNative(
dataPEAK!.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
),
staleTime: 1000 * 60 * 5,
select: (data) => {
const newData = { ...data }
for (const key in newData) {
for (const item of newData[key].PreLevel) {
item.EventIDList = item.EventIDList.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
}
newData[key].BossLevel.EventIDList = newData[key].BossLevel.EventIDList.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
newData[key].BossConfig.EventIDList = newData[key].BossConfig.EventIDList.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
}
return newData
},
enabled: !!dataPEAK,
});
useEffect(() => {
if (dataPEAK && !errorPEAK) {
setPEAKEvent(dataPEAK)
} else if (errorPEAK) {
toast.error("Failed to load PEAK data")
}
}, [dataPEAK, errorPEAK, setPEAKEvent])
useEffect(() => {
if (dataPEAKInfo && !errorPEAKInfo) {
setMapPEAKInfo(dataPEAKInfo as Record<string, PeakDetail>)
} else if (errorPEAKInfo) {
toast.error("Failed to load PEAK info data")
}
}, [dataPEAKInfo, errorPEAKInfo, setMapPEAKInfo])
}

View File

@@ -0,0 +1,66 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { fetchPFByIdsNative, getPFEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
import { toast } from 'react-toastify'
import useEventStore from '@/stores/eventStore'
import { EventStageDetail } from '@/types'
export const useFetchPFData = () => {
const { setPFEvent, setMapPFInfo } = useEventStore()
const { locale } = useLocaleStore()
const { data: dataPF, error: errorPF } = useQuery({
queryKey: ['pfData'],
queryFn: getPFEventListApi,
select: (data) => data.sort((a, b) => Number(b.id) - Number(a.id)),
staleTime: 1000 * 60 * 5,
})
const { data: dataPFInfo, error: errorPFInfo } = useQuery({
queryKey: ['pfInfoData', locale],
queryFn: () =>
fetchPFByIdsNative(
dataPF!.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
),
staleTime: 1000 * 60 * 5,
enabled: !!dataPF,
select: (data) => {
const newData = { ...data }
for (const key in newData) {
for (const item of newData[key].Level) {
item.EventIDList1 = item.EventIDList1.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
item.EventIDList2 = item.EventIDList2.map((event: EventStageDetail) => ({
...event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
MonsterList: event.MonsterList.map(({ $type, ...rest }) => rest)
}))
}
}
return newData
},
});
useEffect(() => {
if (dataPF && !errorPF) {
setPFEvent(dataPF)
} else if (errorPF) {
toast.error("Failed to load PF data")
}
}, [dataPF, errorPF, setPFEvent])
useEffect(() => {
if (dataPFInfo && !errorPFInfo) {
setMapPFInfo(dataPFInfo)
} else if (errorPFInfo) {
toast.error("Failed to load PF info data")
}
}, [dataPFInfo, errorPFInfo, setMapPFInfo])
}

View File

@@ -0,0 +1,46 @@
"use client"
import { useQuery } from '@tanstack/react-query'
import { fetchRelicsByIdsNative, getRelicSetListApi } from '@/lib/api'
import { useEffect } from 'react'
import useRelicStore from '@/stores/relicStore'
import { listCurrentLanguageApi } from '@/constant/constant'
import useLocaleStore from '@/stores/localeStore'
import { toast } from 'react-toastify'
export const useFetchRelicData = () => {
const { setListRelic, setAllMapRelicInfo } = useRelicStore()
const { locale } = useLocaleStore()
const { data: dataRelic, error: errorRelic } = useQuery({
queryKey: ['relicData'],
queryFn: getRelicSetListApi,
staleTime: 1000 * 60 * 5,
})
const { data: dataRelicInfo, error: errorRelicInfo } = useQuery({
queryKey: ['relicInfoData', locale],
queryFn: () =>
fetchRelicsByIdsNative(
dataRelic!.map((item) => item.id),
listCurrentLanguageApi[locale.toLowerCase()]
),
staleTime: 1000 * 60 * 5,
enabled: !!dataRelic,
});
useEffect(() => {
if (dataRelic && !errorRelic) {
setListRelic(dataRelic)
} else if (errorRelic) {
toast.error("Failed to load relic data")
}
}, [dataRelic, errorRelic, setListRelic])
useEffect(() => {
if (dataRelicInfo && !errorRelicInfo) {
setAllMapRelicInfo(dataRelicInfo)
} else if (errorRelicInfo) {
toast.error("Failed to load relic info data")
}
}, [dataRelicInfo, errorRelicInfo, setAllMapRelicInfo])
}

266
src/lib/api/api.ts Normal file
View File

@@ -0,0 +1,266 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AffixDetail, ASDetail, CharacterDetail, ConfigMaze, FreeSRJson, LightConeDetail, MocDetail, MonsterDetail, PeakDetail, PFDetail, PSResponse, RelicDetail } from "@/types";
import axios from 'axios';
import { psResponseSchema } from "@/zod";
import { ExtraData } from "@/types";
export async function getConfigMazeApi(): Promise<ConfigMaze> {
try {
const res = await axios.get<ConfigMaze>(
`/api/config-maze`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as ConfigMaze;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return {
Avatar: {},
MOC: {},
AS: {},
PF: {},
Stage: {},
};
}
}
export async function getMainAffixApi(): Promise<Record<string, Record<string, AffixDetail>>> {
try {
const res = await axios.get<Record<string, Record<string, AffixDetail>>>(
`/api/main-affixes`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as Record<string, Record<string, AffixDetail>>;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return {};
}
}
export async function getSubAffixApi(): Promise<Record<string, Record<string, AffixDetail>>> {
try {
const res = await axios.get<Record<string, Record<string, AffixDetail>>>(
`/api/sub-affixes`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as Record<string, Record<string, AffixDetail>>;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return {};
}
}
export async function fetchCharacterByIdNative(id: string, locale: string): Promise<CharacterDetail | null> {
try {
const res = await axios.get<CharacterDetail>(`/api/${locale}/characters/${id}`);
return res.data;
} catch (error) {
console.error('Failed to fetch character:', error);
return null;
}
}
export async function fetchCharactersByIdsNative(ids: string[], locale: string): Promise<Record<string, CharacterDetail>> {
try {
const res = await axios.post<Record<string, CharacterDetail>>(`/api/${locale}/characters`, { charIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch characters:', error);
return {};
}
}
export async function fetchLightconeByIdNative(id: string, locale: string): Promise<LightConeDetail | null> {
try {
const res = await axios.get<LightConeDetail>(`/api/${locale}/lightcones/${id}`);
return res.data;
} catch (error) {
console.error('Failed to fetch lightcone:', error);
return null;
}
}
export async function fetchLightconesByIdsNative(ids: string[], locale: string): Promise<Record<string, LightConeDetail>> {
try {
const res = await axios.post<Record<string, LightConeDetail>>(`/api/${locale}/lightcones`, { lightconeIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch lightcones:', error);
return {};
}
}
export async function fetchRelicByIdNative(id: string, locale: string): Promise<RelicDetail | null> {
try {
const res = await axios.get<RelicDetail>(`/api/${locale}/relics/${id}`);
return res.data;
} catch (error) {
console.error('Failed to fetch relic:', error);
return null;
}
}
export async function fetchRelicsByIdsNative(ids: string[], locale: string): Promise<Record<string, RelicDetail>> {
try {
const res = await axios.post<Record<string, RelicDetail>>(`/api/${locale}/relics`, { relicIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch relics:', error);
return {};
}
}
export async function fetchASByIdsNative(ids: string[], locale: string): Promise<Record<string, ASDetail> | null> {
try {
const res = await axios.post<Record<string, ASDetail>>(`/api/${locale}/as`, { asIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch AS:', error);
return null;
}
}
export async function fetchASByIdNative(ids: string, locale: string): Promise<ASDetail | null> {
try {
const res = await axios.get<ASDetail>(`/api/${locale}/as/${ids}`);
return res.data;
} catch (error) {
console.error('Failed to fetch AS:', error);
return null;
}
}
export async function fetchPFByIdsNative(ids: string[], locale: string): Promise<Record<string, PFDetail> | null> {
try {
const res = await axios.post<Record<string, PFDetail>>(`/api/${locale}/pf`, { pfIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch PF:', error);
return null;
}
}
export async function fetchPFByIdNative(ids: string, locale: string): Promise<PFDetail | null> {
try {
const res = await axios.get<PFDetail>(`/api/${locale}/pf/${ids}`);
return res.data;
} catch (error) {
console.error('Failed to fetch PF:', error);
return null;
}
}
export async function fetchMOCByIdsNative(ids: string[], locale: string): Promise<Record<string, MocDetail[]> | null> {
try {
const res = await axios.post<Record<string, MocDetail[]>>(`/api/${locale}/moc`, { mocIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch MOC:', error);
return null;
}
}
export async function fetchMOCByIdNative(ids: string, locale: string): Promise<MocDetail[] | null> {
try {
const res = await axios.get<MocDetail[]>(`/api/${locale}/moc/${ids}`);
return res.data;
} catch (error) {
console.error('Failed to fetch MOC:', error);
return null;
}
}
export async function fetchPeakByIdsNative(ids: string[], locale: string): Promise<Record<string, PeakDetail> | null> {
try {
const res = await axios.post<Record<string, PeakDetail>>(`/api/${locale}/peak`, { peakIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch peak:', error);
return null;
}
}
export async function fetchPeakByIdNative(ids: string, locale: string): Promise<PeakDetail | null> {
try {
const res = await axios.get<PeakDetail>(`/api/${locale}/peak/${ids}`);
return res.data;
} catch (error) {
console.error('Failed to fetch peak:', error);
return null;
}
}
export async function fetchMonsterByIdsNative(ids: string[], locale: string): Promise<Record<string, MonsterDetail> | null> {
try {
const res = await axios.post<Record<string, MonsterDetail>>(`/api/${locale}/monster`, { monsterIds: ids });
return res.data;
} catch (error) {
console.error('Failed to fetch monster:', error);
return null;
}
}
export async function fetchMonsterByIdNative(ids: string, locale: string): Promise<MonsterDetail | null> {
try {
const res = await axios.get<MonsterDetail>(`/api/${locale}/monster/${ids}`);
return res.data;
} catch (error) {
console.error('Failed to fetch monster:', error);
return null;
}
}
export async function SendDataToServer(
username: string,
password: string,
serverUrl: string,
data: FreeSRJson | null,
extraData?: ExtraData
): Promise<PSResponse | string> {
try {
const response = await axios.post(`${serverUrl}`, { username, password, data, extra_data: extraData })
const parsed = psResponseSchema.safeParse(response.data)
if (!parsed.success) {
return "Invalid response schema";
}
return parsed.data;
} catch (error: any) {
return error?.message || "Unknown error";
}
}
export async function SendDataThroughProxy({ data }: { data: any }) {
try {
const response = await axios.post(`/api/proxy`, { ...data })
return response.data;
} catch (error: any) {
return error;
}
}

399
src/lib/api/hakushi.ts Normal file
View File

@@ -0,0 +1,399 @@
import { convertAvatar, convertEvent, convertLightcone, convertMonster, convertRelicSet } from "@/helper";
import { ASDetail, CharacterBasic, CharacterBasicRaw, CharacterDetail, EventBasic, EventBasicRaw, LightConeBasic, LightConeBasicRaw, LightConeDetail, MocDetail, MonsterBasic, MonsterBasicRaw, MonsterDetail, MonsterValue, PeakDetail, PFDetail, RelicBasic, RelicBasicRaw, RelicDetail } from "@/types";
import axios from "axios";
export async function getLightconeInfoApi(lightconeId: number, locale: string): Promise<LightConeDetail | null> {
try {
const res = await axios.get<LightConeDetail>(
`https://api.hakush.in/hsr/data/${locale}/lightcone/${lightconeId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as LightConeDetail;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getRelicInfoApi(relicId: number, locale: string): Promise<RelicDetail | null> {
try {
const res = await axios.get<RelicDetail>(
`https://api.hakush.in/hsr/data/${locale}/relicset/${relicId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as RelicDetail;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getCharacterInfoApi(avatarId: number, locale: string): Promise<CharacterDetail | null> {
try {
const res = await axios.get<CharacterDetail>(
`https://api.hakush.in/hsr/data/${locale}/character/${avatarId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as CharacterDetail;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getMOCEventInfoApi(eventId: number, locale: string): Promise<MocDetail[] | null> {
try {
const res = await axios.get<MocDetail[]>(
`https://api.hakush.in/hsr/data/${locale}/maze/${eventId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as MocDetail[];
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getASEventInfoApi(eventId: number, locale: string): Promise<ASDetail | null> {
try {
const res = await axios.get<ASDetail>(
`https://api.hakush.in/hsr/data/${locale}/boss/${eventId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as ASDetail;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getPFEventInfoApi(eventId: number, locale: string): Promise<PFDetail | null> {
try {
const res = await axios.get<PFDetail>(
`https://api.hakush.in/hsr/data/${locale}/story/${eventId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as PFDetail;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getPeakEventInfoApi(eventId: number, locale: string): Promise<PeakDetail | null> {
try {
const res = await axios.get<PeakDetail>(
`https://api.hakush.in/hsr/data/${locale}/peak/${eventId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data as PeakDetail;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getCharacterListApi(): Promise<CharacterBasic[]> {
try {
const res = await axios.get<Record<string, CharacterBasicRaw>>(
'https://api.hakush.in/hsr/data/character.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const data = new Map(Object.entries(res.data));
return Array.from(data.entries()).map(([id, it]) => convertAvatar(id, it));
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return [];
}
}
export async function getLightconeListApi(): Promise<LightConeBasic[]> {
try {
const res = await axios.get<Record<string, LightConeBasicRaw>>(
'https://api.hakush.in/hsr/data/lightcone.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const data = new Map(Object.entries(res.data));
return Array.from(data.entries()).map(([id, it]) => convertLightcone(id, it));
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return [];
}
}
export async function getRelicSetListApi(): Promise<RelicBasic[]> {
try {
const res = await axios.get<Record<string, RelicBasicRaw>>(
'https://api.hakush.in/hsr/data/relicset.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const data = new Map(Object.entries(res.data));
return Array.from(data.entries()).map(([id, it]) => convertRelicSet(id, it));
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return [];
}
}
export async function getMOCEventListApi(): Promise<EventBasic[]> {
try {
const res = await axios.get<Record<string, EventBasicRaw>>(
'https://api.hakush.in/hsr/data/maze.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const data = new Map(Object.entries(res.data));
return Array.from(data.entries()).map(([id, it]) => convertEvent(id, it));
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return [];
}
}
export async function getASEventListApi(): Promise<EventBasic[]> {
try {
const res = await axios.get<Record<string, EventBasicRaw>>(
'https://api.hakush.in/hsr/data/maze_boss.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const data = new Map(Object.entries(res.data));
return Array.from(data.entries()).map(([id, it]) => convertEvent(id, it));
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return [];
}
}
export async function getPFEventListApi(): Promise<EventBasic[]> {
try {
const res = await axios.get<Record<string, EventBasicRaw>>(
'https://api.hakush.in/hsr/data/maze_extra.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const data = new Map(Object.entries(res.data));
return Array.from(data.entries()).map(([id, it]) => convertEvent(id, it));
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return [];
}
}
export async function getPEAKEventListApi(): Promise<EventBasic[]> {
try {
const res = await axios.get<Record<string, EventBasicRaw>>(
'https://api.hakush.in/hsr/data/maze_peak.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const data = new Map(Object.entries(res.data));
return Array.from(data.entries()).map(([id, it]) => convertEvent(id, it));
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return [];
}
}
export async function getMonsterListApi(): Promise<{list: MonsterBasic[], map: Record<string, MonsterBasic>}> {
try {
const res = await axios.get<Record<string, MonsterBasicRaw>>(
'https://api.hakush.in/hsr/data/monster.json',
{
headers: {
'Content-Type': 'application/json',
},
}
);
const dataArr = Array.from(Object.entries(res.data)).map(([id, it]) => convertMonster(id, it));
const dataMap = Object.fromEntries(Object.entries(res.data).map(([id, it]) => [id, convertMonster(id, it)]));
return {
list: dataArr,
map: dataMap
};
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return {list: [], map: {}};
}
}
export async function getMonsterValueApi(): Promise<Record<string, MonsterValue> | null> {
try {
const res = await axios.get<Record<string, MonsterValue>>(
`https://api.hakush.in/hsr/data/monstervalue.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}
export async function getMonsterDetailApi(monsterId: number, locale: string): Promise<MonsterDetail | null> {
try {
const res = await axios.get<MonsterDetail>(
`https://api.hakush.in/hsr/data/${locale}/monster/${monsterId}.json`,
{
headers: {
'Content-Type': 'application/json',
},
}
);
return res.data;
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
console.log(`Error: ${error.response?.status} - ${error.message}`);
} else {
console.log(`Unexpected error: ${String(error)}`);
}
return null;
}
}

2
src/lib/api/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./api";
export * from "./hakushi";

View File

@@ -0,0 +1,32 @@
import { AffixDetail } from '@/types';
import fs from 'fs';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
let mainAffixCache: Record<string, Record<string, AffixDetail>> = {};
let subAffixCache: Record<string, Record<string, AffixDetail>> = {};
const mainAffixPath = path.join(DATA_DIR, `main_affixes.json`)
const subAffixPath = path.join(DATA_DIR, `sub_affixes.json`)
function loadFromFileIfExists(filePath: string): Record<string, Record<string, AffixDetail>> {
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, Record<string, AffixDetail>>;
return data;
}
return {};
}
export async function loadMainAffix(): Promise<Record<string, Record<string, AffixDetail>>> {
if (Object.keys(mainAffixCache).length > 0) return mainAffixCache;
const fileData = loadFromFileIfExists(mainAffixPath);
mainAffixCache = fileData;
return fileData;
}
export async function loadSubAffix(): Promise<Record<string, Record<string, AffixDetail>>> {
if (Object.keys(subAffixCache).length > 0) return subAffixCache;
const fileData = loadFromFileIfExists(subAffixPath);
subAffixCache = fileData;
return fileData;
}

View File

@@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import { ASDetail } from '@/types';
import { getASEventInfoApi } from '../api';
const DATA_DIR = path.join(process.cwd(), 'data');
const asFileCache: Record<string, Record<string, ASDetail>> = {};
export let asMap: Record<string, ASDetail> = {};
function getJsonFilePath(locale: string): string {
return path.join(DATA_DIR, `as.${locale}.json`);
}
function loadFromFileIfExists(locale: string): Record<string, ASDetail> | null {
if (asFileCache[locale]) return asFileCache[locale];
const filePath = getJsonFilePath(locale);
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, ASDetail>;
asFileCache[locale] = data;
return data;
}
return null;
}
export async function loadAS(charIds: string[], locale: string): Promise<Record<string, ASDetail>> {
const fileData = loadFromFileIfExists(locale);
const fileIds = fileData ? Object.keys(fileData) : [];
if (fileData && charIds.every(id => fileIds.includes(id))) {
asMap = fileData;
return asMap;
}
const result: Record<string, ASDetail> = {};
await Promise.all(
charIds.map(async id => {
const info = await getASEventInfoApi(Number(id), locale);
if (info) result[id] = info;
})
);
fs.mkdirSync(DATA_DIR, { recursive: true });
const filePath = getJsonFilePath(locale);
fs.writeFileSync(filePath, JSON.stringify(result), 'utf-8');
asFileCache[locale] = result;
asMap = result;
return result;
}

View File

@@ -0,0 +1,63 @@
import fs from 'fs';
import path from 'path';
import { CharacterDetail } from '@/types/characterDetail';
import { getCharacterInfoApi } from '../api';
const DATA_DIR = path.join(process.cwd(), 'data');
const characterFileCache: Record<string, Record<string, CharacterDetail>> = {};
export let characterMap: Record<string, CharacterDetail> = {};
export let rankIconMap: Record<string, string[]> = {};
function getJsonFilePath(locale: string): string {
return path.join(DATA_DIR, `characters.${locale}.json`);
}
function loadFromFileIfExists(locale: string): Record<string, CharacterDetail> | null {
if (characterFileCache[locale]) return characterFileCache[locale];
const filePath = getJsonFilePath(locale);
const fileRankIconPath = path.join(DATA_DIR, `rank_icon.json`);
if (fs.existsSync(fileRankIconPath)) {
const data = JSON.parse(fs.readFileSync(fileRankIconPath, 'utf-8')) as Record<string, string[]>;
rankIconMap = data;
}
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, CharacterDetail>;
Object.keys(data).forEach((key) => {
data[key].RankIcon = rankIconMap[key] || [];
});
characterFileCache[locale] = data;
return data;
}
return null;
}
export async function loadCharacters(charIds: string[], locale: string): Promise<Record<string, CharacterDetail>> {
const fileData = loadFromFileIfExists(locale);
const fileIds = fileData ? Object.keys(fileData) : [];
if (fileData && charIds.every(id => fileIds.includes(id))) {
characterMap = fileData;
return characterMap;
}
const result: Record<string, CharacterDetail> = {};
await Promise.all(
charIds.map(async id => {
const info = await getCharacterInfoApi(Number(id), locale);
if (info){
info.RankIcon = rankIconMap[id] || [];
result[id] = info;
}
})
);
fs.mkdirSync(DATA_DIR, { recursive: true });
const filePath = getJsonFilePath(locale);
fs.writeFileSync(filePath, JSON.stringify(result), 'utf-8');
characterFileCache[locale] = result;
characterMap = result;
return result;
}

View File

@@ -0,0 +1,23 @@
import { ConfigMaze } from "@/types";
import fs from 'fs';
import path from "path";
const DATA_DIR = path.join(process.cwd(), 'data');
export let configMazeMap: Record<string, ConfigMaze> = {};
function loadFromFileIfExists(): Record<string, ConfigMaze> | null {
const filePath = path.join(DATA_DIR, 'config_maze.json');
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, ConfigMaze>;
configMazeMap = data;
return data;
}
return null;
}
export async function loadConfigMaze(): Promise<Record<string, ConfigMaze>> {
if (Object.keys(configMazeMap).length > 0) return configMazeMap;
const fileData = loadFromFileIfExists();
return fileData || {};
}

10
src/lib/loader/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from "./characterLoader";
export * from "./lighconeLoader";
export * from "./affixLoader";
export * from "./configMazeLoader";
export * from "./relicLoader";
export * from "./asLoader";
export * from "./pfLoader";
export * from "./mocLoader";
export * from "./peakLoader";
export * from "./monsterLoader";

View File

@@ -0,0 +1,64 @@
import fs from 'fs';
import path from 'path';
import { getLightconeInfoApi } from '../api';
import { LightConeDetail } from '@/types';
const DATA_DIR = path.join(process.cwd(), 'data');
const lightconeFileCache: Record<string, Record<string, LightConeDetail>> = {};
export let lightconeMap: Record<string, LightConeDetail> = {};
export let lightconeBonusMap: Record<string, Record<string, { type: string, value: number }[]>> = {};
function getJsonFilePath(locale: string): string {
return path.join(DATA_DIR, `lightcones.${locale}.json`);
}
function loadLightconeFromFileIfExists(locale: string): Record<string, LightConeDetail> | null {
if (lightconeFileCache[locale]) return lightconeFileCache[locale];
const filePath = getJsonFilePath(locale);
const fileBonusPath = path.join(DATA_DIR, `lightcone_bonus.json`);
if (fs.existsSync(fileBonusPath)) {
const data = JSON.parse(fs.readFileSync(fileBonusPath, 'utf-8')) as Record<string, Record<string, { type: string, value: number }[]>>;
lightconeBonusMap = data;
}
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, LightConeDetail>;
Object.keys(data).forEach((key) => {
data[key].Bonus = lightconeBonusMap[key] || {};
});
lightconeFileCache[locale] = data;
return data;
}
return null;
}
export async function loadLightcones(charIds: string[], locale: string): Promise<Record<string, LightConeDetail>> {
const fileData = loadLightconeFromFileIfExists(locale);
const fileIds = fileData ? Object.keys(fileData) : [];
if (fileData && charIds.every(id => fileIds.includes(id))) {
lightconeMap = fileData;
return lightconeMap;
}
const result: Record<string, LightConeDetail> = {};
await Promise.all(
charIds.map(async id => {
const info = await getLightconeInfoApi(Number(id), locale);
if (info) {
info.Bonus = lightconeBonusMap[id] || {};
result[id] = info;
}
})
);
fs.mkdirSync(DATA_DIR, { recursive: true });
const filePath = getJsonFilePath(locale);
fs.writeFileSync(filePath, JSON.stringify(result), 'utf-8');
lightconeFileCache[locale] = result;
lightconeMap = result;
return result;
}

View File

@@ -0,0 +1,51 @@
import fs from 'fs';
import path from 'path';
import { MocDetail } from '@/types';
import { getMOCEventInfoApi } from '../api';
const DATA_DIR = path.join(process.cwd(), 'data');
const mocFileCache: Record<string, Record<string, MocDetail[]>> = {};
export let mocMap: Record<string, MocDetail[]> = {};
function getJsonFilePath(locale: string): string {
return path.join(DATA_DIR, `moc.${locale}.json`);
}
function loadFromFileIfExists(locale: string): Record<string, MocDetail[]> | null {
if (mocFileCache[locale]) return mocFileCache[locale];
const filePath = getJsonFilePath(locale);
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, MocDetail[]>;
mocFileCache[locale] = data;
return data;
}
return null;
}
export async function loadMOC(charIds: string[], locale: string): Promise<Record<string, MocDetail[]>> {
const fileData = loadFromFileIfExists(locale);
const fileIds = fileData ? Object.keys(fileData) : [];
if (fileData && charIds.every(id => fileIds.includes(id))) {
mocMap = fileData;
return mocMap;
}
const result: Record<string, MocDetail[]> = {};
await Promise.all(
charIds.map(async id => {
const info = await getMOCEventInfoApi(Number(id), locale);
if (info) result[id] = info;
})
);
fs.mkdirSync(DATA_DIR, { recursive: true });
const filePath = getJsonFilePath(locale);
fs.writeFileSync(filePath, JSON.stringify(result), 'utf-8');
mocFileCache[locale] = result;
mocMap = result;
return result;
}

View File

@@ -0,0 +1,51 @@
import fs from 'fs';
import path from 'path';
import { MonsterDetail } from '@/types';
import { getMonsterDetailApi } from '../api';
const DATA_DIR = path.join(process.cwd(), 'data');
const monsterFileCache: Record<string, Record<string, MonsterDetail>> = {};
export let monsterMap: Record<string, MonsterDetail> = {};
function getJsonFilePath(locale: string): string {
return path.join(DATA_DIR, `monster.${locale}.json`);
}
function loadFromFileIfExists(locale: string): Record<string, MonsterDetail> | null {
if (monsterFileCache[locale]) return monsterFileCache[locale];
const filePath = getJsonFilePath(locale);
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, MonsterDetail>;
monsterFileCache[locale] = data;
return data;
}
return null;
}
export async function loadMonster(charIds: string[], locale: string): Promise<Record<string, MonsterDetail>> {
const fileData = loadFromFileIfExists(locale);
const fileIds = fileData ? Object.keys(fileData) : [];
if (fileData && charIds.every(id => fileIds.includes(id))) {
monsterMap = fileData;
return monsterMap;
}
const result: Record<string, MonsterDetail> = {};
await Promise.all(
charIds.map(async id => {
const info = await getMonsterDetailApi(Number(id), locale);
if (info) result[id] = info;
})
);
fs.mkdirSync(DATA_DIR, { recursive: true });
const filePath = getJsonFilePath(locale);
fs.writeFileSync(filePath, JSON.stringify(result), 'utf-8');
monsterFileCache[locale] = result;
monsterMap = result;
return result;
}

View File

@@ -0,0 +1,51 @@
import fs from 'fs';
import path from 'path';
import { PeakDetail } from '@/types';
import { getPeakEventInfoApi } from '../api';
const DATA_DIR = path.join(process.cwd(), 'data');
const peakFileCache: Record<string, Record<string, PeakDetail>> = {};
export let peakMap: Record<string, PeakDetail> = {};
function getJsonFilePath(locale: string): string {
return path.join(DATA_DIR, `peak.${locale}.json`);
}
function loadFromFileIfExists(locale: string): Record<string, PeakDetail> | null {
if (peakFileCache[locale]) return peakFileCache[locale];
const filePath = getJsonFilePath(locale);
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record<string, PeakDetail>;
peakFileCache[locale] = data;
return data;
}
return null;
}
export async function loadPeak(charIds: string[], locale: string): Promise<Record<string, PeakDetail>> {
const fileData = loadFromFileIfExists(locale);
const fileIds = fileData ? Object.keys(fileData) : [];
if (fileData && charIds.every(id => fileIds.includes(id))) {
peakMap = fileData;
return peakMap;
}
const result: Record<string, PeakDetail> = {};
await Promise.all(
charIds.map(async id => {
const info = await getPeakEventInfoApi(Number(id), locale);
if (info) result[id] = info;
})
);
fs.mkdirSync(DATA_DIR, { recursive: true });
const filePath = getJsonFilePath(locale);
fs.writeFileSync(filePath, JSON.stringify(result), 'utf-8');
peakFileCache[locale] = result;
peakMap = result;
return result;
}

Some files were not shown because too many files have changed in this diff Show More