Refactor TypeScript interfaces and types across multiple files for consistency and clarity
Gitea Auto Deploy / Deploy-Container (push) Failing after 46s

- Updated metadata interfaces in `metaData.ts` for improved readability.
- Refactored `mics.ts` types to enhance structure and maintainability.
- Added `Invasion` type to `mocDetail.ts` and `peakDetail.ts` for new functionality.
- Simplified modal configuration in `modelConfig.ts`.
- Cleaned up monster detail interfaces in `monsterDetail.ts`.
- Enhanced battle configuration types in `pfDetail.ts` to include `Invasion`.
- Updated JSON schemas in `srtools.zod.ts` to use `nullish` for optional fields.
- Introduced new skills-lock.json for skill management.
- Added global type declarations for CSS and SCSS modules in `global.d.ts`.
This commit is contained in:
2026-08-27 00:41:35 +07:00
parent 7699ac7139
commit 73b6fc268f
133 changed files with 16116 additions and 14971 deletions
+1
View File
@@ -1,6 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
.agents/
.history/
/node_modules
/.pnp
+299 -293
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
Binary file not shown.
+13 -5
View File
@@ -1,18 +1,26 @@
[
{
"version": "4.4.0",
"date": "15/07/2026",
"version": "4.4.5",
"date": "16/07/2026",
"type": "update",
"items": [
"New data for 4.4.0"
"New data for 4.4.5"
]
},
{
"version": "4.3.0",
"version": "4.3.5",
"date": "02/06/2026",
"type": "update",
"items": [
"New data for 4.3.5"
]
},
{
"version": "4.2.5",
"date": "01/05/2026",
"type": "update",
"items": [
"New data for 4.3.0"
"New data for 4.2.5"
]
},
{
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+1
View File
@@ -6,6 +6,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
"node_modules/**",
".next/**",
".history/**",
".agents/**",
"out/**",
"build/**",
"next-env.d.ts",
+4 -1
View File
@@ -228,6 +228,7 @@
"bpskill": "Fähigkeit",
"maze": "Technik",
"ultra": "Ultimate",
"assist": "Assist-Fähigkeit",
"servantskill": "Memosprite-Fähigkeit",
"severaltalent": "Memosprite-Talent",
"singleattack": "Einzelangriff",
@@ -285,6 +286,8 @@
"trailblazer": "Trailblazer",
"listExtraEffect": "Liste Zusatzeffekte",
"extra": "Extra",
"customLineup": "Benutzerdefinierte Aufstellung"
"customLineup": "Benutzerdefinierte Aufstellung",
"def": "VTD",
"language": "Sprache"
}
}
+4 -1
View File
@@ -37,6 +37,7 @@
"wind": "Wind",
"hp": "Hp",
"atk": "Atk",
"def": "Def",
"speed": "Speed",
"critRate": "Crit Rate",
"critDmg": "Crit Dmg",
@@ -228,6 +229,7 @@
"bpskill": "Skill",
"maze": "Technique",
"ultra": "Ultimate",
"assist": "Assist Skill",
"servantskill": "Memosprite Skill",
"severaltalent": "Memosprite Talent",
"singleattack": "Single Attack",
@@ -285,6 +287,7 @@
"trailblazer": "Trailblazer",
"listExtraEffect": "List Extra Effect",
"extra": "Extra",
"customLineup": "Custom Lineup"
"customLineup": "Custom Lineup",
"language": "Language"
}
}
+4 -1
View File
@@ -228,6 +228,7 @@
"bpskill": "Habilidad",
"maze": "Técnica",
"ultra": "Habilidad Definitiva",
"assist": "Habilidad de asistencia",
"servantskill": "Habilidad de Mnemoduende",
"severaltalent": "Talento de Mnemoduende",
"singleattack": "Ataque Individual",
@@ -285,6 +286,8 @@
"trailblazer": "Trazacaminos",
"listExtraEffect": "Lista de Efectos Extra",
"extra": "Extra",
"customLineup": "Alineación de equipo personalizada"
"customLineup": "Alineación de equipo personalizada",
"def": "DEF",
"language": "Idioma"
}
}
+4 -1
View File
@@ -228,6 +228,7 @@
"bpskill": "Compétence",
"maze": "Technique",
"ultra": "Ultime",
"assist": "Compétence d'assistance",
"servantskill": "Compétence de Mémolutin",
"severaltalent": "Talent de Mémolutin",
"singleattack": "Attaque à cible unique",
@@ -285,6 +286,8 @@
"trailblazer": "Pionnier",
"listExtraEffect": "Liste des effets supplémentaires",
"extra": "Extra",
"customLineup": "Composition personnalisée"
"customLineup": "Composition personnalisée",
"def": "DÉF",
"language": "Langue"
}
}
+4 -1
View File
@@ -228,6 +228,7 @@
"bpskill": "Skill",
"maze": "Technique",
"ultra": "Ultimate",
"assist": "Skill Bantuan",
"servantskill": "Skill Memosprite",
"severaltalent": "Talent Memosprite",
"singleattack": "Single Attack",
@@ -285,6 +286,8 @@
"trailblazer": "Trailblazer",
"listExtraEffect": "Daftar Efek Ekstra",
"extra": "Ekstra",
"customLineup": "Lineup Kustom"
"customLineup": "Lineup Kustom",
"def": "DEF",
"language": "Bahasa"
}
}
+4 -1
View File
@@ -225,6 +225,7 @@
"details": "詳細",
"normal": "通常攻撃",
"bpskill": "戦闘スキル",
"assist": "アシストスキル",
"maze": "秘技",
"ultra": "必殺技",
"servantskill": "メモスプライトスキル",
@@ -284,6 +285,8 @@
"trailblazer": "開拓者",
"listExtraEffect": "追加効果一覧",
"extra": "追加",
"customLineup": "カスタム編成"
"customLineup": "カスタム編成",
"def": "防御力",
"language": "言語"
}
}
+4 -1
View File
@@ -225,6 +225,7 @@
"details": "상세",
"normal": "기본 공격",
"bpskill": "스킬",
"assist": "지원 스킬",
"maze": "전술",
"ultra": "필살기",
"servantskill": "메모스프라이트 스킬",
@@ -284,6 +285,8 @@
"trailblazer": "개척자",
"listExtraEffect": "추가 효과 목록",
"extra": "추가",
"customLineup": "커스텀 편성"
"customLineup": "커스텀 편성",
"def": "방어력",
"language": "언어"
}
}
+4 -1
View File
@@ -228,6 +228,7 @@
"bpskill": "Perícia",
"maze": "Técnica",
"ultra": "Perícia Suprema",
"assist": "Habilidade de Assistência",
"servantskill": "Habilidade do Memoespirito",
"severaltalent": "Talento do Memoespirito",
"singleattack": "Ataque Único",
@@ -285,6 +286,8 @@
"trailblazer": "Desbravador",
"listExtraEffect": "Lista de Efeitos Extras",
"extra": "Extra",
"customLineup": "Formação Personalizada"
"customLineup": "Formação Personalizada",
"def": "DEF",
"language": "Idioma"
}
}
+4 -1
View File
@@ -228,6 +228,7 @@
"bpskill": "Навык",
"maze": "Техника",
"ultra": "Сверхспособность",
"assist": "Навык поддержки",
"servantskill": "Навык мемоспрайта",
"severaltalent": "Талант мемоспрайта",
"singleattack": "Одиночная атака",
@@ -285,6 +286,8 @@
"trailblazer": "Первопроходец",
"listExtraEffect": "Список доп. эффектов",
"extra": "Экстра",
"customLineup": "Пользовательский отряд"
"customLineup": "Пользовательский отряд",
"def": "Защ",
"language": "Язык"
}
}
+4 -1
View File
@@ -228,6 +228,7 @@
"bpskill": "สกิลต่อสู้",
"maze": "เทคนิค",
"ultra": "ท่าไม้ตาย",
"assist": "สกิลช่วยเหลือ",
"servantskill": "สกิล Memosprite",
"severaltalent": "พรสวรรค์ Memosprite",
"singleattack": "โจมตีเป้าหมายเดียว",
@@ -285,6 +286,8 @@
"trailblazer": "ผู้บุกเบิก",
"listExtraEffect": "รายการเอฟเฟกต์พิเศษ",
"extra": "พิเศษ",
"customLineup": "จัดทีมแบบกำหนดเอง"
"customLineup": "จัดทีมแบบกำหนดเอง",
"def": "DEF",
"language": "ภาษา"
}
}
+5 -2
View File
@@ -126,7 +126,7 @@
"lightconeEquipment": "Trang bị nón ánh sáng",
"lightconeSettings": "Cài đặt nón ánh sáng",
"placeholderLevel": "Nhập cấp độ",
"superimpositionRank": "Bậc chồng kỹ năng",
"superimpositionRank": "Tích tầng",
"ranksNote": "Bậc càng cao hiệu ứng càng mạnh",
"changeLightcone": "Thay đổi nón ánh sáng",
"removeLightcone": "Gỡ nón ánh sáng",
@@ -228,6 +228,7 @@
"bpskill": "Chiến kỹ",
"maze": "Bí kỹ",
"ultra": "Tuyệt kỹ",
"assist": "Kỹ năng hỗ trợ",
"servantskill": "Kỹ năng vật triệu hồi",
"severaltalent": "Thiên phú vật triệu hồi",
"singleattack": "Tấn công đơn",
@@ -284,6 +285,8 @@
"trailblazer": "Nhà khai phá",
"listExtraEffect": "Danh sách hiệu ứng bổ sung",
"extra": "Bổ sung",
"customLineup": "Đội hình tùy chỉnh"
"customLineup": "Đội hình tùy chỉnh",
"def": "Phòng thủ",
"language": "Ngôn ngữ"
}
}
+4 -1
View File
@@ -225,6 +225,7 @@
"details": "详情",
"normal": "普通攻击",
"bpskill": "技能",
"assist": "协战技",
"maze": "秘技",
"ultra": "终结技",
"servantskill": "忆灵技能",
@@ -284,6 +285,8 @@
"trailblazer": "开拓者",
"listExtraEffect": "额外效果列表",
"extra": "额外",
"customLineup": "自定义阵容"
"customLineup": "自定义阵容",
"def": "防御力",
"language": "语言"
}
}
+12 -12
View File
@@ -10,15 +10,15 @@
},
"dependencies": {
"@next/bundle-analyzer": "16.1.6",
"@tanstack/react-query": "^5.100.10",
"axios": "^1.16.1",
"@tanstack/react-query": "^5.102.5",
"axios": "^1.20.0",
"fast-average-color": "^9.5.2",
"framer-motion": "^12.38.0",
"framer-motion": "^12.43.0",
"html2canvas-pro": "^1.6.7",
"lru-cache": "^11.3.6",
"lru-cache": "^11.5.2",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"next-intl": "^4.12.0",
"next-intl": "^4.13.7",
"prismjs": "^1.30.0",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -27,20 +27,20 @@
"react-toastify": "^11.1.0",
"sharp": "^0.34.5",
"zod": "^4.4.3",
"zustand": "^5.0.13"
"zustand": "^5.0.15"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/jest": "^30.0.0",
"@types/node": "^25.8.0",
"@types/node": "^25.9.5",
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3",
"baseline-browser-mapping": "^2.10.29",
"daisyui": "^5.5.19",
"eslint": "^9.39.4",
"baseline-browser-mapping": "^2.11.19",
"daisyui": "^5.7.22",
"eslint": "^9.39.5",
"eslint-config-next": "16.1.6",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.3.0",
"tailwindcss": "^4.3.3",
"ts-to-zod": "^5.1.0",
"typescript": "^5.9.3"
},
+125
View File
@@ -0,0 +1,125 @@
{
"version": 1,
"skills": {
"banner-design": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/banner-design/SKILL.md",
"computedHash": "f859a951ded1109a38bcc7f18faf4581f9adbf0550df7a11c013e77a5b229541"
},
"brand": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/brand/SKILL.md",
"computedHash": "0e773774d46ecea78eff2b36f574e41830417fcc618181215611f336930deac2"
},
"brandkit": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/brandkit/SKILL.md",
"computedHash": "735007879f5110aeae1e981a53fe74a12afde70347f9773dacd50e4697a091bf"
},
"design": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/design/SKILL.md",
"computedHash": "bb2e3ffee4b7b69e5a38ce08f6010ca79381d9f53b9dfadb33aee1ece6f1679a"
},
"design-system": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/design-system/SKILL.md",
"computedHash": "5a3dbae4a3c2ad9f96323d9bfae926b3c21bb1e3b066aa067906f577a2ff2545"
},
"design-taste-frontend": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/taste-skill/SKILL.md",
"computedHash": "899b84384f74f540ea5284d9b2e9234e050998b42eacc805410b518d4226c0b3"
},
"design-taste-frontend-v1": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/taste-skill-v1/SKILL.md",
"computedHash": "0a226c13d69639553cf8a7c40aa5527b026820f8ddaf7a6b263988e32ab44a27"
},
"full-output-enforcement": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/output-skill/SKILL.md",
"computedHash": "524f7c7950c3fde726101613f630140411c7d080e63be718687f4b38f919be81"
},
"gpt-taste": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/gpt-tasteskill/SKILL.md",
"computedHash": "7bd1bfdda0e51016ca222693401a65fc0dd9bf9487e0bd5b1740ba510f6f963a"
},
"high-end-visual-design": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/soft-skill/SKILL.md",
"computedHash": "f730e4132775f13eea19e3dc39afc6bb453cfc0498872b127ad8f0d47cfd802d"
},
"image-to-code": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/image-to-code-skill/SKILL.md",
"computedHash": "3d2ac14d956fdafc5500d2108b90895e134d847f9f199e7f1d18bf1f814cf7c8"
},
"imagegen-frontend-mobile": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/imagegen-frontend-mobile/SKILL.md",
"computedHash": "7c1d1dc59cd0df4063b8621af543bf5dea1d576b261507e17b0323c69680b048"
},
"imagegen-frontend-web": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/imagegen-frontend-web/SKILL.md",
"computedHash": "27255c7c50181e58add156b2aa2bd5325285b85f04027e4c8b73742d98d7be0a"
},
"industrial-brutalist-ui": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/brutalist-skill/SKILL.md",
"computedHash": "e66e5a2a8b73cef79ab6546459c665e8b983645689c5f3772f7eed8b9f1e8e0e"
},
"minimalist-ui": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/minimalist-skill/SKILL.md",
"computedHash": "7e88897e0371184e97a82b8d9ef080323c13f21eb067b58ef71eb05173a08ac2"
},
"redesign-existing-projects": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/redesign-skill/SKILL.md",
"computedHash": "47eced3e960a2c961d3cbee11fa1216264f589344b586a5cbd03967cd6b9c54b"
},
"slides": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/slides/SKILL.md",
"computedHash": "a2b3559b58afc484548e1eb359e20495d11cf12b90da7c1fbba50efed9a70340"
},
"stitch-design-taste": {
"source": "Leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/stitch-skill/SKILL.md",
"computedHash": "c96b580192b34167efa01f460bbc669ff4315cf97f3cf6ff85ded8029aa720b8"
},
"ui-styling": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/ui-styling/SKILL.md",
"computedHash": "e10b225db04fdfd893fdda2b9ed893c315f9dee5693494743d57b02066e96a51"
},
"ui-ux-pro-max": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/ui-ux-pro-max/SKILL.md",
"computedHash": "d1de8ff8d228cd4204bf06eb959ef4317d5b927908d9f42765971e33e80c8a7e"
}
}
}
+79 -7
View File
@@ -1,6 +1,5 @@
@import "tailwindcss";
@plugin "daisyui" {
exclude: properties;
themes: winter --default, night --prefersdark, cupcake, coffee;
@@ -9,19 +8,19 @@
@tailwind utilities;
@plugin 'tailwind-scrollbar' {
nocompatible: true;
nocompatible: true;
}
:root {
--size-big: 4vw;
--size-medium: 3vw;
--size-small: 2vw;
}
/* Smooth Modern Ultra-Sleek Scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
@@ -29,10 +28,83 @@
}
::-webkit-scrollbar-thumb {
background-color: #9ca3af;
border-radius: 0;
background: rgba(156, 163, 175, 0.28);
border-radius: 9999px;
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(156, 163, 175, 0.55);
}
::-webkit-scrollbar-button {
display: none;
}
/* Custom UI Utilities for Gaming Companion Aesthetic */
@layer utilities {
.glass-card {
background-color: var(--color-base-100);
border: 1px solid color-mix(in srgb, var(--color-base-content) 10%, transparent);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
}
.glass-panel {
background-color: var(--color-base-100);
border: 1px solid color-mix(in srgb, var(--color-base-content) 10%, transparent);
}
.hover-lift {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.25s ease;
}
.hover-lift:hover {
transform: translateY(-2px);
}
.active-press {
transition: transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
}
.active-press:active {
transform: scale(0.97);
}
/* Rarity Glow Effects */
.glow-5star {
box-shadow: 0 0 16px -2px rgba(245, 158, 11, 0.35);
}
.glow-4star {
box-shadow: 0 0 16px -2px rgba(168, 85, 247, 0.35);
}
/* Ambient Background - Clean Canvas */
.bg-ambient-mesh {
background-color: var(--color-base-100);
}
/* Theme Adaptive Path & Stat Icon Filters - High Accessibility Contrast */
.theme-path-icon {
filter: brightness(0.2) opacity(0.85);
}
[data-theme="night"] .theme-path-icon,
[data-theme="coffee"] .theme-path-icon {
filter: brightness(0.8) opacity(0.85);
}
.theme-path-icon-active {
filter: brightness(0);
}
[data-theme="night"] .theme-path-icon-active,
[data-theme="coffee"] .theme-path-icon-active {
filter: brightness(1);
}
/* Stat icons (HSR raw sprite icons are white pngs, need dark filter on light themes) */
.theme-stat-icon {
filter: brightness(0.15) opacity(0.8);
}
[data-theme="night"] .theme-stat-icon,
[data-theme="coffee"] .theme-stat-icon {
filter: brightness(0.95) opacity(1);
}
}
+12 -10
View File
@@ -64,9 +64,9 @@ export default async function RootLayout({
const locale = await getLocale()
return (
<html lang={locale} suppressHydrationWarning>
<html lang={locale} suppressHydrationWarning className="overflow-x-hidden">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-x-hidden max-w-full`}
suppressHydrationWarning
>
<NextIntlClientProvider messages={messages}>
@@ -74,17 +74,19 @@ export default async function RootLayout({
<ThemeProvider>
<ClientThemeWrapper>
<ClientDataFetcher >
<div className="min-h-screen w-full">
<div className="min-h-screen w-full flex flex-col justify-between bg-ambient-mesh selection:bg-primary/20 selection:text-primary">
<Header />
<div className="grid grid-cols-12 w-full">
<div className="hidden sm:block md:col-span-4 lg:col-span-3 sticky top-0 self-start h-fit">
<main className="grid grid-cols-12 w-full flex-1">
<aside className="hidden sm:block sm:col-span-4 md:col-span-4 lg:col-span-3 sticky top-16 self-start h-[calc(100vh-4rem)] overflow-hidden border-r border-base-content/10 bg-base-100/30 backdrop-blur-sm">
<AvatarBar />
</div>
<div className="col-span-12 sm:col-span-8 lg:col-span-9">
</aside>
<section className="col-span-12 sm:col-span-8 md:col-span-8 lg:col-span-9 flex flex-col min-w-0">
<ActionBar />
{children}
</div>
</div>
<div className="flex-1 p-2 sm:p-3 sm:pt-1">
{children}
</div>
</section>
</main>
<Footer />
</div>
+302 -237
View File
@@ -3,15 +3,14 @@
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useRouter } from 'next/navigation'
import { usePathname, 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 { ModalConfig, RelicStore } from "@/types";
import { ModalConfig, RelicStore, AvatarProfileStore } from "@/types";
import { toast } from "react-toastify";
import useGlobalStore from "@/stores/globalStore";
import { connectToPS, syncDataToPS } from "@/helper";
@@ -20,14 +19,28 @@ import useCopyProfileStore from "@/stores/copyProfile";
import AvatarBar from "../avatarBar";
import useCurrentDataStore from "@/stores/currentDataStore";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
import { User, Shield, Sparkles, Zap, Image as ImageIcon, RefreshCw, Plus, Copy, Edit2, Trash2 } from "lucide-react";
export default function ActionBar() {
const router = useRouter()
const { avatarSelected } = useCurrentDataStore()
const { damageType, baseType } = useDetailDataStore()
const { setResetData } = useCopyProfileStore()
const pathname = usePathname()
const { avatarSelected } = useCurrentDataStore(
useShallow((state) => ({ avatarSelected: state.avatarSelected }))
)
const { damageType, baseType } = useDetailDataStore(
useShallow((state) => ({
damageType: state.damageType,
baseType: state.baseType,
}))
)
const { setResetData } = useCopyProfileStore(
useShallow((state) => ({ setResetData: state.setResetData }))
)
const transI18n = useTranslations("DataPage")
const { locale } = useLocaleStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const {
isOpenCreateProfile,
setIsOpenCreateProfile,
@@ -35,17 +48,33 @@ export default function ActionBar() {
setIsOpenCopy,
isOpenAvatars,
setIsOpenAvatars
} = useModelStore()
const { avatars, setAvatar } = useUserDataStore()
} = useModelStore(
useShallow((state) => ({
isOpenCreateProfile: state.isOpenCreateProfile,
setIsOpenCreateProfile: state.setIsOpenCreateProfile,
isOpenCopy: state.isOpenCopy,
setIsOpenCopy: state.setIsOpenCopy,
isOpenAvatars: state.isOpenAvatars,
setIsOpenAvatars: state.setIsOpenAvatars,
}))
)
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatar: state.setAvatar,
}))
)
const [profileName, setProfileName] = useState("");
const [formState, setFormState] = useState("EDIT");
const [profileEdit, setProfileEdit] = useState(-1);
const { isConnectPS } = useGlobalStore()
const { isConnectPS } = useGlobalStore(
useShallow((state) => ({ isConnectPS: state.isConnectPS }))
)
const profileCurrent = useMemo(() => {
if (!avatarSelected) return null;
const avatar = avatars[avatarSelected.ID];
return avatar?.profileList[avatar.profileSelect] || null;
return avatar?.profileList?.[avatar?.profileSelect] ?? avatar?.profileList?.[0] ?? null;
}, [avatarSelected, avatars]);
const listProfile = useMemo(() => {
@@ -54,29 +83,27 @@ export default function ActionBar() {
return avatar?.profileList || [];
}, [avatarSelected, avatars]);
const handleUpdateProfile = () => {
if (!profileName.trim()) return;
if (formState === "CREATE" && avatarSelected && avatars[avatarSelected.ID]) {
const newListProfile = [...listProfile]
const newProfile = {
const newProfile: AvatarProfileStore = {
profile_name: profileName,
lightcone: null,
relics: {} as Record<string, RelicStore>
}
newListProfile.push(newProfile)
};
const newListProfile = [...listProfile, 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;
const newListProfile = listProfile.map((p, idx) =>
idx === profileEdit ? { ...p, profile_name: profileName } : p
);
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) {
@@ -92,28 +119,32 @@ export default function ActionBar() {
}
};
const actionMove = (path: string) => {
router.push(`/${path}`)
}
const handleProfileSelect = (profileId: number) => {
if (!avatarSelected) return;
if (!avatarSelected || !avatars[avatarSelected.ID]) 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 selectedProfile = avatars[avatarSelected.ID].profileList?.[profileId];
if (selectedProfile) {
toast.success(`Profile changed to: ${selectedProfile.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`)
if (!avatarSelected || profileId === 0 || !avatars[avatarSelected.ID]) return;
const targetProfile = avatars[avatarSelected.ID].profileList?.[profileId];
if (!targetProfile) return;
if (window.confirm(`Are you sure you want to delete profile: ${targetProfile.profile_name}?`)) {
const currentSelect = avatars[avatarSelected.ID].profileSelect;
const newListProfile = listProfile.filter((_, idx) => idx !== profileId);
const nextSelect = currentSelect >= profileId ? Math.max(0, currentSelect - 1) : currentSelect;
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile, profileSelect: nextSelect })
toast.success(`Profile ${targetProfile.profile_name} deleted successfully`)
}
}
const handleConnectOrSyncPS = async () => {
@@ -144,23 +175,27 @@ export default function ActionBar() {
handleCloseModal("update_profile_modal")
},
content: (
<div className="px-6 space-y-4">
<div className="space-y-4">
<div className="form-control">
<label className="label">
<span className="label-text text-primary font-semibold text-lg">
<span className="label-text font-semibold text-base-content/80">
{transI18n("profileName")}
</span>
</label>
<input
type="text"
placeholder={transI18n("placeholderProfileName")}
className="input input-warning mt-1 w-full"
className="input input-bordered input-primary w-full"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
autoFocus
/>
</div>
<div className="modal-action">
<button className="btn btn-success btn-sm sm:btn-md" onClick={handleUpdateProfile}>
<div className="flex justify-end gap-2 pt-2">
<button className="btn btn-ghost" onClick={() => setIsOpenCreateProfile(false)}>
Cancel
</button>
<button className="btn btn-primary" onClick={handleUpdateProfile}>
{formState === "CREATE" ? transI18n("create") : transI18n("update")}
</button>
</div>
@@ -169,7 +204,7 @@ export default function ActionBar() {
},
{
id: "copy_profile_modal",
title: transI18n("copyProfiles").toUpperCase(),
title: transI18n("copyProfiles"),
isOpen: isOpenCopy,
onClose: () => {
setIsOpenCopy(false)
@@ -179,7 +214,7 @@ export default function ActionBar() {
},
{
id: "avatars_modal",
title: transI18n("avatars").toUpperCase(),
title: transI18n("avatars"),
isOpen: isOpenAvatars,
onClose: () => {
setIsOpenAvatars(false)
@@ -208,238 +243,268 @@ export default function ActionBar() {
return () => window.removeEventListener('keydown', handleEscKey);
}, [isOpenCopy, isOpenCreateProfile, isOpenAvatars]);
const navTabs = [
{ label: transI18n("characterInformation"), path: "", icon: User },
{ label: transI18n("relics"), path: "relics-info", icon: Shield },
{ label: transI18n("eidolons"), path: "eidolons-info", icon: Sparkles },
{ label: transI18n("skills"), path: "skills-info", icon: Zap },
{ label: transI18n("showcaseCard"), path: "showcase-card", icon: ImageIcon },
];
const currentActivePath = pathname?.replace(/^\//, '') || "";
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="w-full bg-transparent px-3 sm:px-4 py-2 space-y-2">
{/* Top Row: Character Identity + Profile Selector + Connect PS */}
<div className="flex flex-wrap items-center justify-between gap-2.5">
{/* Character Identity */}
{avatarSelected && (
<div className="flex items-center gap-2 h-8.5 px-3 rounded-xl bg-base-200/80 border border-base-content/10 text-xs sm:text-sm font-semibold shadow-xs">
<Image
src={`${process.env.CDN_URL}/${damageType?.[avatarSelected?.DamageType]?.Icon}`}
alt="damage type"
unoptimized
crossOrigin="anonymous"
className="h-5 w-5 object-contain"
width={20}
height={20}
/>
<span className="text-base-content/85 font-medium">{transI18n(avatarSelected.BaseType.toLowerCase())}</span>
<span className="text-base-content/30">/</span>
<ParseText
locale={locale}
text={getNameChar(locale, transI18n, avatarSelected).toWellFormed()}
className="font-bold text-primary"
/>
<span className="text-[11px] font-mono text-base-content/60 font-semibold">#{avatarSelected.ID}</span>
</div>
)}
<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 flex-wrap items-center justify-start h-full w-full">
<Image
src={`${process.env.CDN_URL}/${damageType?.[avatarSelected?.DamageType]?.Icon}`}
alt={'damage type'}
unoptimized
crossOrigin="anonymous"
className="h-10 w-10 object-contain"
width={100}
height={100}
/>
<p className="text-center font-bold text-lg">
{transI18n(avatarSelected.BaseType.toLowerCase())}
</p>
<div className="text-center font-bold text-lg">{" / "}</div>
<ParseText
locale={locale}
text={getNameChar(locale, transI18n, avatarSelected).toWellFormed()}
className={"font-bold text-lg"}
/>
<div className="text-center italic text-sm ml-2"> {`(${avatarSelected.ID})`}</div>
</div>
)}
{/* Profile Selector & Quick Actions */}
<div className="flex items-center h-8.5 rounded-xl bg-base-200/80 border border-base-content/10 p-0.5 shadow-xs">
<div className="dropdown dropdown-bottom dropdown-start h-full flex items-center">
<div
tabIndex={0}
role="button"
className="btn btn-sm btn-ghost gap-1.5 h-7 min-h-7 font-bold rounded-lg hover:bg-base-300/80 px-2"
>
<span className="text-[11px] uppercase tracking-wider text-base-content/60 font-semibold">{transI18n("profile")}:</span>
<span className="truncate max-w-28 sm:max-w-40 text-xs text-base-content font-bold">
{profileCurrent?.profile_name || "Default Profile"}
</span>
<svg className="w-3.5 h-3.5 opacity-60 ml-0.5" 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-50 menu p-2 shadow-2xl bg-base-100 rounded-2xl min-w-60 mt-2 border border-base-300 max-h-72 overflow-y-auto space-y-1">
{listProfile.map((profile, index) => (
<li key={index} className="flex flex-row items-center justify-between rounded-xl px-1 hover:bg-base-200">
<button
className="flex-1 text-left py-2 px-2 text-xs font-medium truncate"
onClick={() => handleProfileSelect(index)}
>
<span className="truncate">{profile.profile_name}</span>
{index === 0 && (
<span className="text-[10px] text-base-content/50 ml-1.5 font-mono">({transI18n("default")})</span>
)}
</button>
{index !== 0 && (
<div className="flex items-center gap-1">
<button
className="btn btn-ghost btn-xs btn-circle text-warning hover:bg-warning/20"
onClick={() => {
setFormState("EDIT");
setProfileName(profile.profile_name)
setProfileEdit(index)
setIsOpenCreateProfile(true)
handleShow("update_profile_modal")
}}
title="Edit Profile"
>
<Edit2 className="w-3 h-3" />
</button>
<button
className="btn btn-ghost btn-xs btn-circle text-error hover:bg-error/20"
onClick={(e) => handleDeleteProfile(index, e)}
title="Delete Profile"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</li>
))}
<li className="border-t border-base-300 pt-1 mt-1">
<button
onClick={() => {
setIsOpenCopy(true)
setResetData(baseType, damageType)
handleShow("copy_profile_modal")
}}
className="flex items-center gap-2 py-2 px-3 text-xs font-semibold text-primary hover:bg-primary/10 rounded-xl"
>
<Copy className="w-3.5 h-3.5" />
{transI18n("copyProfiles")}
</button>
</li>
<li>
<button
onClick={() => {
setIsOpenCreateProfile(true)
setFormState("CREATE");
setProfileName("")
handleShow("update_profile_modal")
}}
className="flex items-center gap-2 py-2 px-3 text-xs font-semibold text-primary hover:bg-primary/10 rounded-xl"
>
<Plus className="w-3.5 h-3.5" />
{transI18n("addNewProfile")}
</button>
</li>
</ul>
</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>
{/* Quick Profile Actions */}
<div className="flex items-center border-l border-base-content/10 pl-1 pr-0.5 gap-0.5 h-5">
<button
onClick={() => {
setIsOpenCopy(true)
setResetData(baseType, damageType)
handleShow("copy_profile_modal")
}}
className="btn btn-ghost btn-xs btn-circle h-6 w-6 min-h-6 text-base-content/60 hover:text-primary hover:bg-primary/10"
title={transI18n("copyProfiles")}
>
<Copy className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
setIsOpenCreateProfile(true)
setFormState("CREATE");
setProfileName("")
handleShow("update_profile_modal")
}}
className="btn btn-ghost btn-xs btn-circle h-6 w-6 min-h-6 text-base-content/60 hover:text-primary hover:bg-primary/10"
title={transI18n("addNewProfile")}
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</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>
{/* Desktop Connect PS Button */}
<div className="hidden sm:flex items-center gap-2 ml-auto tooltip tooltip-bottom" data-tip={isConnectPS ? transI18n("sync") : transI18n("connectPs")}>
<button
onClick={handleConnectOrSyncPS}
className={`btn btn-sm h-8.5 min-h-8.5 rounded-xl px-3.5 gap-1.5 font-bold shadow-xs whitespace-nowrap active-press ${
isConnectPS
? "btn-outline btn-accent"
: "btn-primary shadow-sm"
}`}
>
<RefreshCw className={`w-3.5 h-3.5 ${isConnectPS ? "text-accent" : ""}`} />
<span className="text-xs">{isConnectPS ? transI18n("sync") : transI18n("connectPs")}</span>
</button>
</div>
</div>
</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>
))}
{/* Mobile-Only Action Row (< 640px): [Avatars Button] & [Actions Dropdown] */}
<div className="grid grid-cols-2 sm:hidden gap-2 w-full pt-1">
<button
onClick={() => {
setIsOpenAvatars(true);
handleShow("avatars_modal");
}}
className="btn btn-warning btn-sm w-full font-bold shadow-xs"
>
{transI18n("avatars")}
</button>
<li className="border-t border-base-300 mt-2 pt-2 z-10">
<div className="dropdown dropdown-end w-full">
<label tabIndex={0} className="btn btn-info btn-sm w-full font-bold shadow-xs">
{transI18n("actions")}
</label>
<ul
tabIndex={0}
className="dropdown-content z-50 menu p-2 shadow-2xl bg-base-100 rounded-2xl w-56 mt-2 border border-base-300 space-y-1"
>
{navTabs.map((tab) => {
const Icon = tab.icon;
return (
<li key={tab.path}>
<button
onClick={() => {
setIsOpenCopy(true)
setResetData(baseType, damageType)
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"
onClick={() => actionMove(tab.path)}
className="flex items-center gap-2 py-2 px-3 text-xs font-semibold"
>
<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")}
<Icon className="w-4 h-4" />
<span>{tab.label}</span>
</button>
</li>
</ul>
</div>
<div className=" grid grid-cols-2 w-full sm:hidden gap-2">
);
})}
<li className="border-t border-base-300 pt-1 mt-1">
<button
onClick={() => {
setIsOpenAvatars(true)
handleShow("avatars_modal")
}}
className="col-span-1 btn btn-warning btn-sm w-full">
{transI18n("avatars")}
onClick={handleConnectOrSyncPS}
className="btn btn-primary btn-sm w-full font-bold mt-1"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>{isConnectPS ? transI18n("sync") : transI18n("connectPs")}</span>
</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>
</li>
</ul>
</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>
{/* Navigation Tabs Row (>= 640px) */}
<div className="hidden sm:block w-full bg-base-200/80 border border-base-content/10 rounded-xl p-1 shadow-xs overflow-x-auto scrollbar-none">
<div className="flex sm:grid sm:grid-cols-5 w-full min-w-max gap-1">
{navTabs.map((tab) => {
const isActive = currentActivePath === tab.path;
const Icon = tab.icon;
return (
<button
key={tab.path}
onClick={() => actionMove(tab.path)}
className={`flex items-center justify-center gap-2 py-2 px-3 rounded-lg text-xs font-bold transition-all duration-200 whitespace-nowrap active-press ${
isActive
? "bg-primary text-primary-content shadow-xs"
: "text-base-content/75 hover:text-base-content hover:bg-base-100"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span>{tab.label}</span>
</button>
);
})}
</div>
</div>
{/* Modals */}
{modalConfigs.map(({ id, title, onClose, content }) => (
<dialog key={id} id={id} className="modal">
<div className="modal-box w-11/12 max-w-[90%] 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"
<div className="modal-box w-11/12 max-w-[92vw] sm:max-w-[88vw] max-h-[90vh] bg-base-100 text-base-content border border-base-300 shadow-2xl rounded-2xl p-0 overflow-hidden">
<div className="sticky top-0 z-20 flex items-center justify-between px-6 py-4 border-b border-base-300 bg-base-100">
<h3 className="font-bold text-xl tracking-tight text-primary">
{title}
</h3>
<button
className="btn btn-sm btn-circle btn-ghost text-base-content/60 hover:text-base-content hover:bg-base-200 transition-colors"
onClick={onClose}
>
✕
</motion.button>
</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 className="p-4 sm:p-6 overflow-y-auto max-h-[calc(90vh-5rem)]">
{content}
</div>
{content}
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
))}
</div>
</div>
);
}
+140 -81
View File
@@ -7,6 +7,8 @@ import { useTranslations } from "next-intl"
import useDetailDataStore from "@/stores/detailDataStore"
import useCurrentDataStore from '@/stores/currentDataStore';
import { calcRarity, getNameChar } from "@/helper"
import { useShallow } from "zustand/react/shallow";
import { Search, X } from "lucide-react"
export default function AvatarBar({ onClose }: { onClose?: () => void }) {
const {
@@ -18,10 +20,29 @@ export default function AvatarBar({ onClose }: { onClose?: () => void }) {
setMapAvatarElementActive,
setMapAvatarPathActive,
setSkillIDSelected,
} = useCurrentDataStore()
const { mapAvatar, baseType, damageType } = useDetailDataStore()
} = useCurrentDataStore(
useShallow((state) => ({
avatarSearch: state.avatarSearch,
mapAvatarElementActive: state.mapAvatarElementActive,
mapAvatarPathActive: state.mapAvatarPathActive,
setAvatarSearch: state.setAvatarSearch,
setAvatarSelected: state.setAvatarSelected,
setMapAvatarElementActive: state.setMapAvatarElementActive,
setMapAvatarPathActive: state.setMapAvatarPathActive,
setSkillIDSelected: state.setSkillIDSelected,
}))
)
const { mapAvatar, baseType, damageType } = useDetailDataStore(
useShallow((state) => ({
mapAvatar: state.mapAvatar,
baseType: state.baseType,
damageType: state.damageType,
}))
)
const transI18n = useTranslations("DataPage")
const {locale} = useLocaleStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const listAvatar = useMemo(() => {
if (!mapAvatar || !locale || !transI18n) return []
@@ -53,87 +74,125 @@ export default function AvatarBar({ onClose }: { onClose?: () => void }) {
return list
}, [mapAvatar, mapAvatarElementActive, mapAvatarPathActive, avatarSearch, locale, transI18n])
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={avatarSearch}
onChange={(e) => setAvatarSearch(e.target.value)}
<div className="flex flex-col h-full gap-2 p-2">
{/* Compact Search Bar */}
<label className="input input-sm input-bordered flex items-center gap-2 bg-base-200/90 border-base-300 rounded-xl w-full h-8.5 px-3 shadow-xs focus-within:border-primary/60 transition-colors">
<Search className="w-4 h-4 text-base-content/70 shrink-0" />
<input
type="text"
placeholder={transI18n("placeholderCharacter")}
className="grow text-xs bg-transparent border-none outline-none focus:outline-none placeholder:text-base-content/50"
value={avatarSearch}
onChange={(e) => setAvatarSearch(e.target.value)}
/>
{avatarSearch && (
<button
type="button"
onClick={() => setAvatarSearch("")}
className="text-base-content/50 hover:text-base-content"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</label>
{/* Compact Filter Section */}
<div className="p-1 rounded-xl bg-base-200/80 border border-base-content/15 space-y-1 shadow-xs">
{/* Element Filters - 7 Elements on 1 Row (gap-0.5) */}
<div className="grid grid-cols-7 gap-0.5">
{Object.entries(damageType).filter(([key]) => key !== "").map(([key, value]) => {
const isActive = !!mapAvatarElementActive[key];
return (
<button
key={key}
type="button"
onClick={() => {
setMapAvatarElementActive({ ...mapAvatarElementActive, [key]: !mapAvatarElementActive[key] })
}}
className={`h-8.5 w-full rounded-lg transition-all duration-150 active-press flex items-center justify-center p-0 ${
isActive
? "bg-primary/20 text-primary ring-1.5 ring-primary scale-105 z-10 shadow-xs"
: "hover:bg-base-100 opacity-90 hover:opacity-100"
}`}
title={value.DamageTypeName || key}
>
<Image
src={`${process.env.CDN_URL}/${value.Icon}`}
alt={key}
unoptimized
crossOrigin="anonymous"
className="w-6.5 h-6.5 object-contain drop-shadow-xs"
width={32}
height={32}
/>
</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.entries(damageType).filter(([key]) => key !== "").map(([key, value]) => (
<div
key={key}
onClick={() => {
setMapAvatarElementActive({ ...mapAvatarElementActive, [key]: !mapAvatarElementActive[key] })
}}
className="hover:bg-gray-600 grid items-center justify-items-center cursor-pointer rounded-md shadow-lg"
style={{
backgroundColor: mapAvatarElementActive[key] ? "#374151" : "#6B7280"
}}>
<Image
src={`${process.env.CDN_URL}/${value.Icon}`}
alt={key}
unoptimized
crossOrigin="anonymous"
className="h-7 w-7 2xl:h-10 2xl:w-10 object-contain rounded-md"
width={200}
height={200} />
</div>
))}
</div>
<div className="grid grid-cols-9 sm:grid-cols-5 lg:grid-cols-9 mb-1 mx-1 gap-2 overflow-y-auto w-full max-h-[17vh] min-h-[5vh]">
{Object.entries(baseType).filter(([key]) => key !== "").map(([key, value]) => (
<div
key={key}
onClick={() => {
setMapAvatarPathActive({ ...mapAvatarPathActive, [key]: !mapAvatarPathActive[key] })
}}
className="hover:bg-gray-600 grid items-center justify-items-center rounded-md shadow-lg cursor-pointer"
style={{
backgroundColor: mapAvatarPathActive[key] ? "#374151" : "#6B7280"
}}
>
<Image
src={`${process.env.CDN_URL}/${value.Icon}`}
unoptimized
crossOrigin="anonymous"
alt={key}
className="h-7 w-7 2xl:h-10 2xl:w-10 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);
setSkillIDSelected(null)
if (onClose) onClose()
}}>
<CharacterCard data={item} />
</div>
))}
</ul>
</div>
</div>
</button>
);
})}
</div>
{/* Path Filters - 9 Paths on 1 Row (gap-0.5) */}
<div className="grid grid-cols-9 gap-0.5">
{Object.entries(baseType).filter(([key]) => key !== "").map(([key, value]) => {
const isActive = !!mapAvatarPathActive[key];
return (
<button
key={key}
type="button"
onClick={() => {
setMapAvatarPathActive({ ...mapAvatarPathActive, [key]: !mapAvatarPathActive[key] })
}}
className={`h-8 w-full rounded-lg transition-all duration-150 active-press flex items-center justify-center p-0 ${
isActive
? "bg-primary/20 ring-1.5 ring-primary scale-105 z-10 shadow-xs"
: "hover:bg-base-100 opacity-90 hover:opacity-100"
}`}
title={value.BaseTypeName || key}
>
<Image
src={`${process.env.CDN_URL}/${value.Icon}`}
unoptimized
crossOrigin="anonymous"
alt={key}
className={`w-5.5 h-5.5 object-contain ${
isActive ? "theme-path-icon-active" : "theme-path-icon"
}`}
width={28}
height={28}
/>
</button>
);
})}
</div>
</div>
{/* Character Count & Grid List */}
<div className="flex items-center justify-between text-xs font-semibold text-base-content/80 px-1 py-0.5">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></span>
<span>{transI18n("character")}</span>
</span>
<span className="font-mono text-xs font-bold px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20">
{listAvatar.length}
</span>
</div>
<div className="flex-1 overflow-y-auto pr-0.5 scrollbar-thin">
<ul className="grid grid-cols-3 sm:grid-cols-2 lg:grid-cols-3 gap-1.5 auto-rows-fr">
{listAvatar.map((item) => (
<div
key={item.ID}
className="h-full flex flex-col"
onClick={() => {
setAvatarSelected(item);
setSkillIDSelected(null)
if (onClose) onClose()
}}
>
<CharacterCard data={item} />
</div>
))}
</ul>
</div>
</div>
)
+387 -441
View File
@@ -3,7 +3,6 @@
import useUserDataStore from "@/stores/userDataStore";
import { useEffect, useMemo } from "react";
import { motion } from "framer-motion";
import LightconeBar from '../lightconeBar'
import { calcPromotion, calcRarity, replaceByParam } from '@/helper';
import { getSkillTree } from '@/helper/getSkillTree';
@@ -15,20 +14,62 @@ import Image from 'next/image';
import useCurrentDataStore from "@/stores/currentDataStore";
import useDetailDataStore from "@/stores/detailDataStore";
import { getLocaleName } from "@/helper/getName";
import { useShallow } from "zustand/react/shallow";
export default function AvatarInfo() {
const { avatarSelected, setResetDataLightcone } = useCurrentDataStore()
const { avatars, setAvatars, setAvatar } = useUserDataStore()
const { isOpenLightcone, setIsOpenLightcone } = useModelStore()
const { mapLightCone, baseType } = useDetailDataStore()
const { avatarSelected, setResetDataLightcone } = useCurrentDataStore(
useShallow((state) => ({
avatarSelected: state.avatarSelected,
setResetDataLightcone: state.setResetDataLightcone,
}))
)
const { avatars, setAvatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatars: state.setAvatars,
setAvatar: state.setAvatar,
}))
)
const { isOpenLightcone, setIsOpenLightcone } = useModelStore(
useShallow((state) => ({
isOpenLightcone: state.isOpenLightcone,
setIsOpenLightcone: state.setIsOpenLightcone,
}))
)
const { mapLightCone, baseType } = useDetailDataStore(
useShallow((state) => ({
mapLightCone: state.mapLightCone,
baseType: state.baseType,
}))
)
const transI18n = useTranslations("DataPage")
const { locale } = useLocaleStore();
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
);
const lightcone = useMemo(() => {
if (!avatarSelected) return null;
const avatar = avatars[avatarSelected.ID];
return avatar?.profileList[avatar.profileSelect]?.lightcone || null;
return avatar?.profileList?.[avatar?.profileSelect]?.lightcone ?? avatar?.profileList?.[0]?.lightcone ?? null;
}, [avatarSelected, avatars]);
const handleUpdateLightcone = (updater: (lc: typeof lightcone) => typeof lightcone) => {
if (!avatarSelected || !avatars[avatarSelected.ID]) return;
const avatar = avatars[avatarSelected.ID];
const profileIndex = avatar.profileSelect;
const currentProfile = avatar.profileList?.[profileIndex];
if (!currentProfile) return;
const newProfileList = [...avatar.profileList];
newProfileList[profileIndex] = {
...currentProfile,
lightcone: updater(currentProfile.lightcone),
};
setAvatar({
...avatar,
profileList: newProfileList,
});
};
const lightconeDetail = useMemo(() => {
if (!mapLightCone || !lightcone?.item_id) return null;
return mapLightCone?.[lightcone.item_id.toString()] || null;
@@ -70,460 +111,365 @@ export default function AvatarInfo() {
}, [isOpenLightcone]);
return (
<div className="bg-base-100 max-h-[77vh] min-h-[50vh] overflow-y-scroll overflow-x-hidden">
<div className="w-full space-y-4">
{avatarSelected && avatars[avatarSelected?.ID.toString() || ""] && (
<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.toString() || ""]?.level}/80</span>
</label>
<div className="relative">
<input
type="number"
min="1"
max="80"
value={avatars[avatarSelected?.ID.toString() || ""]?.level}
onChange={(e) => {
const newLevel = Math.min(80, Math.max(1, parseInt(e.target.value) || 1));
setAvatars({ ...avatars, [avatarSelected?.ID.toString() || ""]: { ...avatars[avatarSelected?.ID.toString() || ""], level: newLevel, promotion: calcPromotion(newLevel) } });
}}
className="input input-bordered w-full pr-16 font-mono"
placeholder={transI18n("placeholderLevel")}
/>
<div
onClick={() => {
setAvatars({ ...avatars, [avatarSelected?.ID.toString() || ""]: { ...avatars[avatarSelected?.ID.toString() || ""], 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.toString() || ""]?.level}
onChange={(e) => {
const newLevel = Math.min(80, Math.max(1, parseInt(e.target.value) || 1));
setAvatars({ ...avatars, [avatarSelected?.ID.toString() || ""]: { ...avatars[avatarSelected?.ID.toString() || ""], 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.toString() || ""]?.sp_value)}/{avatars[avatarSelected?.ID.toString() || ""]?.sp_max}
</span>
</label>
<input
type="range"
min="0"
max={avatars[avatarSelected?.ID.toString() || ""]?.sp_max}
value={avatars[avatarSelected?.ID.toString() || ""]?.sp_value}
onChange={(e) => {
if (!avatars[avatarSelected?.ID.toString() || ""]?.can_change_sp) return
const newSpValue = Math.min(avatars[avatarSelected?.ID.toString() || ""]?.sp_max, Math.max(0, parseInt(e.target.value) || 0));
setAvatars({ ...avatars, [avatarSelected?.ID.toString() || ""]: { ...avatars[avatarSelected?.ID.toString() || ""], 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.toString() || ""]?.sp_value / avatars[avatarSelected?.ID.toString() || ""]?.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.toString() || ""]?.can_change_sp) return
const newSpValue = Math.ceil(avatars[avatarSelected?.ID.toString() || ""]?.sp_max / 2);
const newAvatar = { ...avatars[avatarSelected?.ID.toString() || ""], 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.toString() || ""]?.techniques.length > 0 }
onChange={(e) => {
if (!avatarSelected || avatarSelected?.MazeBuff?.length == 0) return
const techniques = e.target.checked ? avatarSelected?.MazeBuff : [];
const newAvatar = { ...avatars[avatarSelected?.ID.toString() || ""], techniques: 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 */}
{avatarSelected?.Enhanced && (
<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.toString() || ""]?.enhanced || transI18n("origin")}
</span>
</label>
<select
value={avatars[avatarSelected?.ID.toString() || ""]?.enhanced || ""}
onChange={(e) => {
const newAvatar = avatars[avatarSelected?.ID.toString() || ""]
if (newAvatar) {
newAvatar.enhanced = e.target.value
const skillTree = getSkillTree(avatarSelected, e.target.value)
if (skillTree) {
newAvatar.data.skills = skillTree
}
setAvatar(newAvatar)
}
}}
className="select select-bordered select-secondary"
>
<option value="">{transI18n("origin")}</option>
{Object.keys(avatarSelected?.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 className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 w-full items-start">
{/* Left Column: Character Configuration */}
<div className="glass-card rounded-2xl p-5 sm:p-6 border border-base-content/10 shadow-xs flex flex-col justify-between">
<div>
{/* Card Header */}
<div className="flex items-center justify-between pb-4 mb-2 border-b border-base-content/10">
<h2 className="text-base sm:text-lg font-bold flex items-center gap-2.5">
<div className="w-1.5 h-5 bg-linear-to-b from-primary to-accent rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-primary to-accent font-bold tracking-tight">
{transI18n("characterSettings")}
</span>
</h2>
<span className="text-xs font-mono font-semibold px-2.5 py-1 rounded-md bg-primary/10 text-primary border border-primary/20">
ID: #{avatarSelected.ID}
</span>
</div>
<div className="divide-y divide-base-content/10">
{/* Level Control */}
<div className="py-4 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-base-content tracking-tight flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
{transI18n("characterLevel")}
</span>
<span className="text-xs font-mono font-bold px-2.5 py-1 rounded-md bg-primary/15 text-primary border border-primary/25">
{avatars[avatarSelected?.ID.toString() || ""]?.level} / 80
</span>
</div>
<div className="flex items-center gap-3 pt-0.5">
<input
type="range"
min="1"
max="80"
value={avatars[avatarSelected?.ID.toString() || ""]?.level}
onChange={(e) => {
const newLevel = Math.min(80, Math.max(1, parseInt(e.target.value) || 1));
setAvatars({ ...avatars, [avatarSelected?.ID.toString() || ""]: { ...avatars[avatarSelected?.ID.toString() || ""], level: newLevel, promotion: calcPromotion(newLevel) } });
}}
className="range range-primary range-sm flex-1"
/>
<button
onClick={() => {
setAvatars({ ...avatars, [avatarSelected?.ID.toString() || ""]: { ...avatars[avatarSelected?.ID.toString() || ""], level: 80, promotion: calcPromotion(80) } });
}}
className="btn btn-sm btn-primary btn-outline h-8 min-h-8 rounded-lg font-bold px-3"
>
{transI18n("max")}
</button>
</div>
</div>
{/* Ultimate Energy Control */}
<div className="py-4 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-base-content tracking-tight flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
{transI18n("ultimateEnergy")}
</span>
<div className="flex items-center gap-2">
<span className="text-xs font-mono font-bold text-amber-500">
{Math.round(avatars[avatarSelected?.ID.toString() || ""]?.sp_value)} / {avatars[avatarSelected?.ID.toString() || ""]?.sp_max}
</span>
<span className="text-xs font-mono text-base-content/70 font-semibold">
({((avatars[avatarSelected?.ID.toString() || ""]?.sp_value / avatars[avatarSelected?.ID.toString() || ""]?.sp_max) * 100).toFixed(0)}%)
</span>
</div>
</div>
<div className="flex items-center gap-3 pt-0.5">
<input
type="range"
min="0"
max={avatars[avatarSelected?.ID.toString() || ""]?.sp_max}
value={avatars[avatarSelected?.ID.toString() || ""]?.sp_value}
onChange={(e) => {
if (!avatars[avatarSelected?.ID.toString() || ""]?.can_change_sp) return
const newSpValue = Math.min(avatars[avatarSelected?.ID.toString() || ""]?.sp_max, Math.max(0, parseInt(e.target.value) || 0));
setAvatars({ ...avatars, [avatarSelected?.ID.toString() || ""]: { ...avatars[avatarSelected?.ID.toString() || ""], sp_value: newSpValue } });
}}
className="range range-warning range-sm flex-1"
/>
<button
className="btn btn-sm btn-warning btn-outline h-8 min-h-8 font-bold px-3 rounded-lg shadow-xs active-press"
onClick={() => {
if (!avatars[avatarSelected?.ID.toString() || ""]?.can_change_sp) return
const newSpValue = Math.ceil(avatars[avatarSelected?.ID.toString() || ""]?.sp_max / 2);
const newAvatar = { ...avatars[avatarSelected?.ID.toString() || ""], sp_value: newSpValue }
setAvatar(newAvatar)
}}
>
{transI18n("setTo50")}
</button>
</div>
</div>
{/* Technique Toggle */}
<div className="py-4 flex items-center justify-between gap-4">
<div className="space-y-1">
<span className="text-sm font-bold text-base-content tracking-tight flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-secondary" />
{transI18n("useTechnique")}
</span>
<p className="text-xs text-base-content/70 font-medium pl-3">{transI18n("techniqueNote")}</p>
</div>
<input
type="checkbox"
checked={avatars[avatarSelected?.ID.toString() || ""]?.techniques.length > 0}
onChange={(e) => {
if (!avatarSelected || avatarSelected?.MazeBuff?.length == 0) return
const techniques = e.target.checked ? avatarSelected?.MazeBuff : [];
const newAvatar = { ...avatars[avatarSelected?.ID.toString() || ""], techniques: techniques };
setAvatar(newAvatar);
}}
className="toggle toggle-primary"
/>
</div>
{/* Enhancement Selection */}
{avatarSelected?.Enhanced && (
<div className="py-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-bold text-base-content tracking-tight flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
{transI18n("enhancement")}
</span>
<span className="text-xs font-mono text-secondary font-bold">
{avatars[avatarSelected?.ID.toString() || ""]?.enhanced || transI18n("origin")}
</span>
</div>
<select
value={avatars[avatarSelected?.ID.toString() || ""]?.enhanced || ""}
onChange={(e) => {
const newAvatar = avatars[avatarSelected?.ID.toString() || ""]
if (newAvatar) {
newAvatar.enhanced = e.target.value
const skillTree = getSkillTree(avatarSelected, e.target.value)
if (skillTree) {
newAvatar.data.skills = skillTree
}
setAvatar(newAvatar)
}
}}
className="select select-sm select-bordered w-full rounded-xl bg-base-200/50"
>
<option value="">{transI18n("origin")}</option>
{Object.keys(avatarSelected?.Enhanced || {}).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
<p className="text-xs text-base-content/60 font-medium">{transI18n("enhancedNote")}</p>
</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>
{/* Right Column: Lightcone Equipment */}
<div className="glass-card rounded-2xl p-5 sm:p-6 border border-base-content/10 shadow-xs flex flex-col justify-between">
<div>
{/* Card Header */}
<div className="flex items-center justify-between pb-4 mb-2 border-b border-base-content/10">
<h2 className="text-base sm:text-lg font-bold flex items-center gap-2.5">
<div className="w-1.5 h-5 bg-linear-to-b from-secondary to-primary rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-secondary to-primary font-bold tracking-tight">
{transI18n("lightconeEquipment")}
</span>
</h2>
{lightcone && (
<span className="text-xs font-mono font-semibold px-2.5 py-1 rounded-md bg-secondary/10 text-secondary border border-secondary/20">
S{lightcone.rank} • Lv.{lightcone.level}
</span>
)}
</div>
{lightcone && lightconeDetail ? (
<div className="space-y-4">
{/* Lightcone Hero Block */}
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4 items-center">
{/* Artwork */}
<div className="sm:col-span-4 aspect-[3/4] rounded-xl overflow-hidden bg-base-200/60 flex items-center justify-center border border-base-content/10 shadow-xs">
<Image
unoptimized
crossOrigin="anonymous"
width={904}
height={1260}
priority
src={`${process.env.CDN_URL}/${lightconeDetail?.Image?.ImagePath}`}
className="w-full h-full object-cover"
alt="Lightcone"
/>
</div>
{/* Meta & Stats */}
<div className="sm:col-span-8 space-y-3">
<div>
<h3 className="text-base sm:text-lg font-bold tracking-tight text-base-content">
<ParseText
locale={locale}
text={getLocaleName(locale, lightconeDetail.Name)}
/>
</h3>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className="text-xs px-2.5 py-0.5 rounded-md bg-base-200 font-semibold text-base-content/80 border border-base-content/10">
{transI18n(lightconeDetail.BaseType.toLowerCase())}
</span>
<span className="text-xs px-2.5 py-0.5 rounded-md bg-amber-500/15 text-amber-500 font-bold border border-amber-500/25">
{calcRarity(lightconeDetail.Rarity)}★
</span>
<span className="text-xs font-mono text-base-content/60 font-medium">#{lightcone.item_id}</span>
</div>
</div>
{/* Level Slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs font-semibold">
<span className="text-base-content/80">{transI18n("level")}</span>
<span className="font-mono text-primary font-bold">{lightcone.level}/80</span>
</div>
<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))
handleUpdateLightcone((lc) => lc ? { ...lc, level: newLightconeLevel, promotion: calcPromotion(newLightconeLevel) } : null)
}}
className="range range-primary range-sm w-full"
/>
</div>
{/* Superimposition Rank (S1-S5) */}
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs font-semibold">
<span className="text-base-content/80">{transI18n("superimpositionRank")}</span>
<span className="font-mono text-info font-bold">S{lightcone.rank}</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{[1, 2, 3, 4, 5].map((r) => (
<button
key={r}
onClick={() => {
handleUpdateLightcone((lc) => lc ? { ...lc, rank: r } : null)
}}
className={`btn btn-xs rounded-lg font-mono font-bold transition-all ${
lightcone.rank === r
? 'btn-primary shadow-xs'
: 'btn-ghost bg-base-200 hover:bg-base-300 text-base-content/80'
}`}
>
S{r}
</button>
))}
</div>
</div>
</div>
</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>
{/* Skill Description */}
<div className="p-3.5 rounded-xl bg-base-200/50 border border-base-content/10">
<div
className="text-xs sm:text-sm text-base-content/90 leading-relaxed font-normal"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, lightconeDetail.Skills.Desc),
lightconeDetail.Skills.Level[lightcone.rank.toString()]?.Param || []
)
}}
/>
</div>
<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) {
setResetDataLightcone(avatarSelected, baseType)
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
unoptimized
crossOrigin="anonymous"
width={904}
height={1260}
priority
src={`${process.env.CDN_URL}/${lightconeDetail?.Image?.ImagePath}`}
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 */}
{lightconeDetail && (
<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={getLocaleName(locale, lightconeDetail.Name)}
/>
</h3>
<div className="badge badge-outline badge-lg">
{transI18n(lightconeDetail.BaseType.toLowerCase())}
</div>
<div className="badge badge-outline badge-lg">
{calcRarity(lightconeDetail.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(
getLocaleName(locale, lightconeDetail.Skills.Desc),
lightconeDetail.Skills.Level[lightcone.rank.toString()]?.Param || []
)
}}
/>
</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) {
setResetDataLightcone(avatarSelected, baseType)
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>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={() => {
if (avatarSelected) {
setResetDataLightcone(avatarSelected, baseType)
handleShow("action_detail_modal")
}
}}
className="btn btn-primary btn-sm flex-1 rounded-xl font-bold shadow-xs"
>
{transI18n("changeLightcone")}
</button>
<button
onClick={() => {
handleUpdateLightcone(() => null)
}}
className="btn btn-error btn-outline hover:btn-error btn-sm rounded-xl font-bold px-3.5 gap-1.5 transition-all"
title={transI18n("removeLightcone")}
>
<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-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-xs">{transI18n("removeLightcone")}</span>
</button>
</div>
</div>
</div>
) : (
/* Empty Lightcone State */
<div className="text-center py-8 space-y-3.5">
<div
onClick={() => handleShow("action_detail_modal")}
className="w-16 h-16 mx-auto bg-base-200/70 rounded-2xl border-2 border-dashed border-base-content/30 flex items-center justify-center cursor-pointer hover:border-primary hover:scale-105 transition-all group"
>
<svg className="w-7 h-7 text-base-content/50 group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div>
<h3 className="text-sm font-bold text-base-content">{transI18n("noLightconeEquipped")}</h3>
<p className="text-xs text-base-content/60 font-medium mt-0.5">{transI18n("equipLightconeNote")}</p>
</div>
<button
onClick={() => {
if (avatarSelected) {
setResetDataLightcone(avatarSelected, baseType)
handleShow("action_detail_modal")
}
}}
className="btn btn-primary btn-sm rounded-xl font-bold px-6 shadow-xs"
>
{transI18n("equipLightcone")}
</button>
</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"
{/* Lightcone Selection Modal */}
<dialog id="action_detail_modal" className="modal">
<div className="modal-box w-11/12 max-w-[92vw] sm:max-w-[88vw] max-h-[90vh] bg-base-100 text-base-content border border-white/10 shadow-2xl rounded-2xl p-0 overflow-hidden">
<div className="sticky top-0 z-20 flex items-center justify-between px-6 py-4 border-b border-base-content/10 bg-base-100">
<h3 className="font-bold text-xl tracking-tight bg-clip-text text-transparent bg-linear-to-r from-primary via-accent to-secondary">
{transI18n("lightconeEquipment")}
</h3>
<button
className="btn btn-sm btn-circle btn-ghost text-base-content/60 hover:text-base-content hover:bg-base-200 transition-colors"
onClick={() => handleCloseModal("action_detail_modal")}
>
✕
</motion.button>
</button>
</div>
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(90vh-5rem)]">
<LightconeBar />
</div>
<LightconeBar />
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={() => handleCloseModal("action_detail_modal")}>close</button>
</form>
</dialog>
</div>
);
}
+77 -44
View File
@@ -6,78 +6,111 @@ import ParseText from '../parseText';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import useDetailDataStore from '@/stores/detailDataStore';
import { useShallow } from 'zustand/react/shallow';
import useUserDataStore from '@/stores/userDataStore';
interface CharacterCardProps {
data: AvatarDetail
}
export default function CharacterCard({ data }: CharacterCardProps) {
const { locale } = useLocaleStore();
const { locale } = useLocaleStore(useShallow((state) => ({ locale: state.locale })));
const transI18n = useTranslations("DataPage");
const { baseType, damageType } = useDetailDataStore()
const { baseType, damageType } = useDetailDataStore(
useShallow((state) => ({
baseType: state.baseType,
damageType: state.damageType,
}))
)
const { avatars } = useUserDataStore(
useShallow((state) => ({ avatars: state.avatars }))
)
const userAvatar = avatars[data.ID.toString()]
const level = userAvatar?.level
const rank = userAvatar?.data?.rank ?? 0
const is5Star = data.Rarity === "CombatPowerAvatarRarityType5";
const is4Star = data.Rarity === "CombatPowerAvatarRarityType4";
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-45 sm:min-h-45 md:min-h-52.5 lg:min-h-55 xl:min-h-60 2xl:min-h-65"
className="group relative flex flex-col items-center rounded-xl overflow-hidden bg-base-100 border border-base-content/10 shadow-sm hover:shadow-md cursor-pointer select-none h-full"
>
{/* Artwork container with vibrant rarity background */}
<div
className={`w-full rounded-md bg-linear-to-br ${data.Rarity === "CombatPowerAvatarRarityType5"
? "from-yellow-400 via-yellow-600/70 to-yellow-800/50"
: "from-purple-400 via-purple-600/70 to-purple-800/50"
}`}
className={`w-full relative aspect-[3/4] flex items-center justify-center overflow-hidden bg-linear-to-b ${
is5Star
? "from-yellow-400 via-amber-500/85 to-amber-700/70"
: is4Star
? "from-purple-400 via-purple-600/80 to-purple-800/60"
: "from-sky-400 via-blue-600/80 to-blue-800/60"
}`}
>
<Image
width={376}
height={512}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${data.Image.AvatarIconPath}`}
priority={true}
className="w-full h-full object-contain transition-transform duration-300 ease-out group-hover:scale-105"
alt={data.ID.toString()}
/>
<div className="relative w-full h-32 lg:h-26 xl:h-36">
{/* Level and Eidolons Badge Overlay */}
{level && (
<div className="absolute bottom-1 left-1 flex items-center gap-1 z-10">
<span className="bg-black/60 backdrop-blur-xs text-white text-[10px] font-bold font-mono px-1.5 py-0.2 rounded shadow-xs">
{level}
</span>
{rank > 0 && (
<span className="bg-amber-500/80 text-black text-[10px] font-black font-mono px-1 py-0.2 rounded shadow-xs">
E{rank}
</span>
)}
</div>
)}
{/* Damage Type Badge */}
{damageType?.[data.DamageType]?.Icon && (
<Image
width={376}
height={512}
width={28}
height={28}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${data.Image.AvatarIconPath}`}
priority={true}
className="rounded-md w-full h-full object-contain"
alt="ALT"
/>
<Image
width={32}
height={32}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType?.[data.DamageType].Icon}`}
className="absolute top-0 left-0 w-6 h-6 rounded-full"
src={`${process.env.CDN_URL}/${damageType[data.DamageType].Icon}`}
className="absolute top-1 left-1 w-6 h-6 object-contain drop-shadow-sm"
alt={data.DamageType.toLowerCase()}
/>
)}
{/* Base Type / Path Badge */}
{baseType?.[data.BaseType]?.Icon && (
<Image
width={32}
height={32}
width={28}
height={28}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${baseType?.[data.BaseType].Icon}`}
className="absolute top-0 right-0 w-6 h-6 rounded-full"
src={`${process.env.CDN_URL}/${baseType[data.BaseType].Icon}`}
className="absolute top-1 right-1 w-6 h-6 object-contain rounded-full drop-shadow-sm"
alt={data.BaseType.toLowerCase()}
style={{
boxShadow: "inset 0 0 8px 4px #9CA3AF"
boxShadow: "inset 0 0 6px 3px rgba(255, 255, 255, 0.4)"
}}
/>
</div>
)}
</div>
<ParseText
locale={locale}
text={getNameChar(locale, transI18n, data)}
className="
w-full px-0.5
my-1
text-center font-bold text-shadow-white
leading-tight
wrap-break-word
text-sm sm:text-base 2xl:text-lg
"
/>
{/* Name Label - Clear Full Name Display */}
<div className="w-full py-1.5 px-1 text-center flex items-center justify-center bg-base-100 min-h-11">
<ParseText
locale={locale}
text={getNameChar(locale, transI18n, data)}
className="font-bold text-xs sm:text-[11.5px] lg:text-xs leading-tight px-0.5 text-base-content group-hover:text-primary transition-colors text-center w-full"
/>
</div>
</li>
);
}
+108 -76
View File
@@ -7,84 +7,118 @@ import Image from 'next/image';
import ParseText from '../parseText';
import useDetailDataStore from '@/stores/detailDataStore';
import { getLocaleName } from '@/helper/getName';
import { useShallow } from 'zustand/react/shallow';
export default function CharacterInfoCard({ character, selectedCharacters, onCharacterToggle }: { character: CharacterInfoCardType, selectedCharacters: CharacterInfoCardType[], onCharacterToggle: (characterId: CharacterInfoCardType) => void }) {
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 { mapAvatar, mapLightCone, baseType, damageType } = useDetailDataStore();
const { locale } = useLocaleStore();
const { mapAvatar, mapLightCone, baseType, damageType } = useDetailDataStore(
useShallow((state) => ({
mapAvatar: state.mapAvatar,
mapLightCone: state.mapLightCone,
baseType: state.baseType,
damageType: state.damageType,
}))
);
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
);
const avatar = mapAvatar?.[character.avatar_id.toString()];
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'
}`}
className={`group rounded-2xl p-4 border transition-all duration-200 cursor-pointer select-none ${
isSelected
? 'bg-primary/10 border-primary shadow-lg shadow-primary/20 ring-2 ring-primary/40'
: 'bg-base-200/60 border-white/5 hover:border-white/20 opacity-80 hover:opacity-100 hover:bg-base-200'
}`}
onClick={() => onCharacterToggle(character)}
>
{/* Character Portrait */}
<div className="relative mb-4">
<div className="w-full h-48 rounded-lg overflow-hidden relative">
<Image
src={`${process.env.CDN_URL}/${mapAvatar?.[character.avatar_id.toString()]?.Image?.AvatarIconPath}`}
alt={getLocaleName(locale, mapAvatar?.[character.avatar_id.toString()]?.Name)}
width={376}
height={512}
unoptimized
crossOrigin="anonymous"
className="w-full h-full object-contain"
/>
<Image
width={48}
height={48}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType?.[mapAvatar?.[character.avatar_id.toString()]?.DamageType || ""]?.Icon}`}
className="absolute top-0 left-0 w-10 h-10 rounded-full"
alt={mapAvatar[character.avatar_id.toString()]?.DamageType.toLowerCase()}
/>
<Image
width={48}
height={48}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${baseType?.[mapAvatar?.[character.avatar_id.toString()]?.BaseType || ""]?.Icon}`}
className="absolute top-0 right-0 w-10 h-10 rounded-full"
alt={mapAvatar[character.avatar_id.toString()]?.BaseType.toLowerCase()}
style={{
boxShadow: "inset 0 0 8px 4px #9CA3AF"
}}
/>
<div className="relative mb-3 aspect-[4/5] rounded-xl overflow-hidden bg-base-300/40 flex items-center justify-center">
{avatar && (
<>
<Image
src={`${process.env.CDN_URL}/${avatar.Image?.AvatarIconPath}`}
alt={getLocaleName(locale, avatar.Name)}
width={376}
height={512}
unoptimized
crossOrigin="anonymous"
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
/>
{damageType?.[avatar.DamageType]?.Icon && (
<div className="absolute top-1.5 left-1.5 p-0.5 rounded-full bg-black/60 backdrop-blur-xs border border-white/10">
<Image
width={24}
height={24}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType[avatar.DamageType].Icon}`}
className="w-5 h-5 object-contain"
alt={avatar.DamageType.toLowerCase()}
/>
</div>
)}
{baseType?.[avatar.BaseType]?.Icon && (
<div className="absolute top-1.5 right-1.5 p-0.5 rounded-full bg-black/60 backdrop-blur-xs border border-white/10">
<Image
width={24}
height={24}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${baseType[avatar.BaseType].Icon}`}
className="w-5 h-5 object-contain"
alt={avatar.BaseType.toLowerCase()}
/>
</div>
)}
</>
)}
</div>
{/* Character Name and Level Badge */}
<div className="text-center mb-3">
<ParseText
className="text-base font-bold line-clamp-1"
text={avatar ? getLocaleName(locale, avatar.Name) : character.avatar_id.toString()}
locale={locale}
/>
<div className="flex items-center justify-center gap-2 mt-1">
<span className="text-xs font-mono font-semibold px-2 py-0.5 rounded-full bg-base-300/80 text-base-content/80">
Lv.{character.level}
</span>
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
E{character.rank}
</span>
</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={getLocaleName(locale, mapAvatar[character.avatar_id.toString()]?.Name)}
locale={locale}
/>
<div className="text-base mb-1">Lv.{character.level} E{character.rank}</div>
</div>
</div>
{/* Relics Mini-grid */}
{character.relics.length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-2 mb-4">
<div className="flex flex-wrap items-center justify-center gap-1.5 mb-3 p-1.5 rounded-xl bg-base-100/50 border border-white/5">
{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">
<div key={index} className="relative group/relic">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-base-200 border border-amber-500/40">
<Image
src={`${process.env.CDN_URL}/spriteoutput/relicfigures/IconRelic_${relic.relic_set_id}_${relic.relic_id.toString()[relic.relic_id.toString().length - 1]}.png`}
alt="Relic"
unoptimized
crossOrigin="anonymous"
width={124}
height={124}
className="w-14 h-14 object-contain"
width={32}
height={32}
className="w-6 h-6 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">
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 bg-black/80 text-[9px] font-mono font-bold text-amber-400 px-1 rounded-full border border-white/10">
+{relic.level}
</div>
</div>
@@ -93,30 +127,28 @@ export default function CharacterInfoCard({ character, selectedCharacters, onCha
)}
{/* Light Cone */}
{character.lightcone.item_id && (
<div className="">
<div className="rounded-lg h-42 flex items-center justify-center">
{character.lightcone && character.lightcone.item_id !== 0 && (
<div className="p-2 rounded-xl bg-base-100/50 border border-white/5 text-center">
<div className="h-28 flex items-center justify-center mb-1">
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${mapLightCone?.[character.lightcone.item_id.toString()]?.Image?.ImagePath}`}
alt={getLocaleName(locale, mapLightCone?.[character.lightcone.item_id.toString()]?.Name)}
width={348}
height={408}
className="w-full h-full object-contain rounded-lg"
alt="Lightcone"
width={160}
height={200}
className="h-full w-auto 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={getLocaleName(locale, mapLightCone[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 className="text-xs font-bold truncate">
<ParseText
text={getLocaleName(locale, mapLightCone?.[character.lightcone.item_id.toString()]?.Name)}
locale={locale}
/>
</div>
<div className="flex items-center justify-center gap-2 mt-1">
<span className="text-[11px] font-mono text-base-content/70">Lv.{character.lightcone.level}</span>
<span className="text-[11px] font-mono font-bold text-primary">S{character.lightcone.rank}</span>
</div>
</div>
)}
+33 -31
View File
@@ -2,6 +2,7 @@
import { getLocaleName } from '@/helper';
import useLocaleStore from '@/stores/localeStore';
import { useShallow } from 'zustand/react/shallow';
import ParseText from '../parseText';
import Image from 'next/image';
import { LightConeDetail } from '@/types';
@@ -11,43 +12,44 @@ interface LightconeCardProps {
}
export default function LightconeCard({ data }: LightconeCardProps) {
const { locale } = useLocaleStore(useShallow((state) => ({ locale: state.locale })));
const text = getLocaleName(locale, data.Name);
const is5Star = data.Rarity === "CombatPowerLightconeRarity5";
const is4Star = data.Rarity === "CombatPowerLightconeRarity4";
const { locale } = useLocaleStore();
const text = getLocaleName(locale, data.Name)
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-55"
>
<li className="group relative flex flex-col items-center rounded-xl overflow-hidden bg-base-100 border border-base-content/10 shadow-sm hover:shadow-md cursor-pointer select-none h-full">
{/* Artwork container with vibrant rarity background */}
<div
className={`w-full rounded-md bg-linear-to-br ${data.Rarity === "CombatPowerLightconeRarity5"
? "from-yellow-400 via-yellow-600/70 to-yellow-800/50"
: data.Rarity === "CombatPowerLightconeRarity4" ? "from-purple-400 via-purple-600/70 to-purple-800/50" :
"from-blue-400 via-blue-600/70 to-blue-800/50"
}`}
className={`w-full relative aspect-[3/4] flex items-center justify-center overflow-hidden bg-linear-to-b ${
is5Star
? "from-yellow-400 via-amber-500/85 to-amber-700/70"
: is4Star
? "from-purple-400 via-purple-600/80 to-purple-800/60"
: "from-sky-400 via-blue-600/80 to-blue-800/60"
}`}
>
<div className="relative w-full h-full">
<Image
loading="lazy"
src={`${process.env.CDN_URL}/${data?.Image?.ThumbnailPath}`}
unoptimized
crossOrigin="anonymous"
width={348}
height={408}
className="w-full h-full rounded-md object-cover"
alt="ALT"
/>
</div>
<Image
loading="lazy"
src={`${process.env.CDN_URL}/${data?.Image?.ThumbnailPath}`}
unoptimized
crossOrigin="anonymous"
width={348}
height={408}
className="w-full h-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
alt={data?.Name?.en || "Lightcone"}
/>
</div>
<ParseText
locale={locale}
text={text}
className="mt-2 px-1 text-center text-sm sm:text-base font-bold leading-tight"
/>
{/* Name Label - Uniform Fixed-Height Footer */}
<div className="w-full h-11 px-1 py-0.5 text-center flex items-center justify-center bg-base-100">
<ParseText
locale={locale}
text={text}
className="font-bold text-xs leading-tight line-clamp-2 text-base-content group-hover:text-primary transition-colors text-center"
/>
</div>
</li>
);
}
+44 -33
View File
@@ -7,67 +7,78 @@ import Image from 'next/image';
import ParseText from '../parseText';
import useDetailDataStore from '@/stores/detailDataStore';
import { getLocaleName } from '@/helper/getName';
import { useShallow } from 'zustand/react/shallow';
export default function ProfileCard({ profile, selectedProfile, onProfileToggle }: { profile: AvatarProfileCardType, selectedProfile: AvatarProfileCardType[], onProfileToggle: (profileId: AvatarProfileCardType) => void }) {
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 { mapLightCone } = useDetailDataStore();
const { locale } = useLocaleStore();
const { mapLightCone } = useDetailDataStore(
useShallow((state) => ({ mapLightCone: state.mapLightCone }))
);
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
);
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'
}`}
className={`group rounded-2xl p-4 border transition-all duration-200 cursor-pointer select-none ${
isSelected
? 'bg-primary/10 border-primary shadow-md ring-2 ring-primary/40'
: 'bg-base-200/80 border-base-300 hover:border-primary/40 hover:bg-base-200'
}`}
onClick={() => onProfileToggle(profile)}
>
{/* Light Cone */}
{profile.lightcone && (
<div className="">
<div className="rounded-lg h-42 flex items-center justify-center">
<div className="p-2 rounded-xl bg-base-100 border border-base-300 text-center mb-3">
<div className="h-32 flex items-center justify-center mb-2">
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/spriteoutput/lightconemaxfigures/${profile.lightcone.item_id}.png`}
alt={getLocaleName(locale, mapLightCone[profile.lightcone.item_id.toString()]?.Name)}
width={348}
height={408}
className="w-full h-full object-contain rounded-lg"
alt="Lightcone"
width={240}
height={300}
className="h-full w-auto object-contain rounded-lg transition-transform duration-200 group-hover:scale-105"
/>
</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={getLocaleName(locale, mapLightCone[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 className="text-sm font-bold truncate">
<ParseText
text={getLocaleName(locale, mapLightCone?.[profile.lightcone.item_id.toString()]?.Name)}
locale={locale}
/>
</div>
<div className="flex items-center justify-center gap-2 mt-1">
<span className="text-xs font-mono font-medium text-base-content/70">Lv.{profile.lightcone.level}</span>
<span className="text-xs font-mono font-bold text-primary">S{profile.lightcone.rank}</span>
</div>
</div>
)}
{/* Relics Mini-grid */}
{Object.keys(profile.relics).length > 0 && (
<div className="flex flex-wrap items-center justify-center gap-2 mb-4">
<div className="flex flex-wrap items-center justify-center gap-1.5 p-1.5 rounded-xl bg-base-100 border border-base-300">
{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">
<div key={index} className="relative group/relic">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-base-200 border border-amber-500/40">
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/spriteoutput/relicfigures/IconRelic_${relic.relic_set_id}_${relic.relic_id.toString()[relic.relic_id.toString().length - 1]}.png`}
alt="Relic"
width={124}
height={124}
className="w-14 h-14 object-contain"
width={32}
height={32}
className="w-6 h-6 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">
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 bg-black/80 text-[9px] font-mono font-bold text-amber-400 px-1 rounded-full border border-white/10">
+{relic.level}
</div>
</div>
+82 -143
View File
@@ -2,6 +2,7 @@
import useRelicMakerStore from "@/stores/relicMakerStore";
import useUserDataStore from "@/stores/userDataStore";
import { useShallow } from "zustand/react/shallow";
import Image from "next/image";
import { useMemo } from "react";
@@ -9,188 +10,126 @@ 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';
case '6':
return 'border-amber-400/90 shadow-[0_0_16px_rgba(251,191,36,0.4)] bg-linear-to-br from-amber-600 via-amber-400 to-amber-500';
case '5':
return 'border-purple-400/90 shadow-[0_0_16px_rgba(192,132,252,0.4)] bg-linear-to-br from-purple-700 via-purple-400 to-purple-500';
case '4':
return 'border-sky-400/90 shadow-[0_0_16px_rgba(56,189,248,0.4)] bg-linear-to-br from-blue-700 via-blue-400 to-sky-500';
case '3':
return 'border-emerald-400/90 shadow-[0_0_16px_rgba(52,211,153,0.4)] bg-linear-to-br from-emerald-700 via-emerald-400 to-emerald-500';
default:
return 'border-base-content/20 bg-base-300';
}
};
const getRarityName = (slot: string) => {
switch (slot) {
case '1': return (
<div className="flex items-center gap-1">
<Image
unoptimized
crossOrigin="anonymous"
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
unoptimized
crossOrigin="anonymous"
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
unoptimized
crossOrigin="anonymous"
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
unoptimized
crossOrigin="anonymous"
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
unoptimized
crossOrigin="anonymous"
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
unoptimized
crossOrigin="anonymous"
src="/relics/OBJECT.png"
alt="Object"
width={20}
height={20}
className="bg-black/50 rounded-full"
/>
<h2>Link rope</h2>
</div>
);
default: return '';
}
const getSlotIconAndName = (slot: string) => {
const slotMap: Record<string, { icon: string; name: string }> = {
'1': { icon: '/relics/HEAD.png', name: 'Head' },
'2': { icon: '/relics/HAND.png', name: 'Hands' },
'3': { icon: '/relics/BODY.png', name: 'Body' },
'4': { icon: '/relics/FOOT.png', name: 'Feet' },
'5': { icon: '/relics/NECK.png', name: 'Planar Sphere' },
'6': { icon: '/relics/OBJECT.png', name: 'Link Rope' },
};
const current = slotMap[slot];
if (!current) return null;
return (
<div className="flex items-center gap-1.5 justify-center mt-2.5">
<Image
unoptimized
crossOrigin="anonymous"
src={current.icon}
alt={current.name}
width={18}
height={18}
className="w-4 h-4 object-contain theme-stat-icon opacity-85"
/>
<span className="text-xs font-bold text-base-content">{current.name}</span>
</div>
);
};
export default function RelicCard({ slot, avatarId }: RelicCardProps) {
const { avatars } = useUserDataStore()
const { selectedRelicSlot } = useRelicMakerStore()
const { avatars } = useUserDataStore(
useShallow((state) => ({ avatars: state.avatars }))
)
const { selectedRelicSlot } = useRelicMakerStore(
useShallow((state) => ({ selectedRelicSlot: state.selectedRelicSlot }))
)
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;
const profile = avatar.profileList?.[avatar.profileSelect] ?? avatar.profileList?.[0];
return profile?.relics?.[slot] || null;
}
return null;
}, [avatars, avatarId, slot]);
const isSelected = selectedRelicSlot === slot;
return (
<div>
<div className="flex flex-col items-center select-none group cursor-pointer">
{relicDetail ? (
<div
className="flex flex-col items-center cursor-pointer ">
<div className="flex flex-col items-center">
<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'}
`}
relative w-22 h-22 sm:w-24 sm:h-24 rounded-full border-3
${getRarityColor(relicDetail.relic_id.toString()[0])}
flex items-center justify-center
transition-all duration-200
${isSelected ? 'ring-2 ring-primary/70 shadow-md ring-offset-2 ring-offset-base-100' : 'hover:border-primary/60'}
`}
>
<span>
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/spriteoutput/relicfigures/IconRelic_${relicDetail.relic_set_id}_${slot}.png`}
alt="Relic"
width={124}
height={124}
className="w-14 h-14 object-contain"
/>
</span>
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/spriteoutput/relicfigures/IconRelic_${relicDetail.relic_set_id}_${slot}.png`}
alt="Relic"
width={124}
height={124}
className="w-14 h-14 sm:w-16 sm:h-16 object-contain drop-shadow-md"
/>
{/* 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 className="absolute -bottom-2 bg-base-100 border border-base-content/15 shadow-md rounded-full px-2 py-0.5">
<span className="text-xs font-mono 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>
{getSlotIconAndName(slot)}
</div>
) : (
<div
className="flex flex-col items-center cursor-pointer">
<div className="flex flex-col items-center">
<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
`}
relative w-22 h-22 sm:w-24 sm:h-24 rounded-full border-2 border-dashed border-base-content/20
bg-base-200/60 flex items-center justify-center
transition-all duration-200 group-hover:border-primary/60
${isSelected ? 'ring-2 ring-primary/70 border-solid bg-primary/10 ring-offset-2 ring-offset-base-100' : ''}
`}
>
<span className="text-3xl">
<svg className="w-12 h-12 text-base-content/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="flex flex-col items-center justify-center gap-1 text-base-content/40 group-hover:text-primary transition-colors">
<svg className="w-8 h-8" 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>
</div>
{/* 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 className="absolute -bottom-2 bg-base-100 border border-base-content/15 shadow-md rounded-full px-2 py-0.5">
<span className="text-xs font-mono font-bold text-base-content/40">+0</span>
</div>
</div>
<div className="mt-3 text-center">
<div>{getRarityName(slot)}</div>
</div>
{getSlotIconAndName(slot)}
</div>
)}
</div>
)
}
+58 -43
View File
@@ -2,7 +2,8 @@
import Image from 'next/image';
import { AvatarDetail } from '@/types';
import useDetailDataStore from '@/stores/detailDataStore';
import { useShallow } from 'zustand/react/shallow';
import { X } from 'lucide-react';
interface SimpleAvatarCardProps {
data: AvatarDetail;
@@ -12,58 +13,72 @@ interface SimpleAvatarCardProps {
}
export const SimpleAvatarCard = ({ data, isSelected, onClick, showRemoveHover }: SimpleAvatarCardProps) => {
const { baseType, damageType } = useDetailDataStore()
const { baseType, damageType } = useDetailDataStore(
useShallow((state) => ({
baseType: state.baseType,
damageType: state.damageType,
}))
)
const is5Star = data.Rarity === "CombatPowerAvatarRarityType5";
return (
<div
onClick={onClick}
className={`relative w-16 h-16 sm:w-20 sm:h-20 rounded-md cursor-pointer transition-transform duration-200 ease-in-out hover:scale-105 shadow-md shrink-0
${isSelected ? 'ring-2 ring-success opacity-60' : ''}
bg-linear-to-br ${data.Rarity === "CombatPowerAvatarRarityType5"
? "from-yellow-400 via-yellow-600/70 to-yellow-800/50"
: "from-purple-400 via-purple-600/70 to-purple-800/50"
}`}
className={`group relative w-16 h-16 sm:w-20 sm:h-20 rounded-xl cursor-pointer shadow-sm hover:shadow-md shrink-0 border border-base-content/10 overflow-hidden select-none ${
isSelected ? 'ring-2 ring-primary opacity-60 scale-95' : ''
}`}
>
<Image
width={80}
height={80}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${data.Image.ActionAvatarHeadIconPath}`}
priority={true}
className="w-full h-full object-contain"
alt="Avatar"
/>
<div className="absolute top-0 left-0 w-5 h-5 bg-black/40 rounded-full flex items-center justify-center p-0.5">
<div
className={`relative w-full h-full rounded-xl overflow-hidden flex items-center justify-center bg-linear-to-b ${
is5Star
? "from-yellow-400 via-amber-500/85 to-amber-700/70"
: "from-purple-400 via-purple-600/80 to-purple-800/60"
}`}
>
<Image
width={20}
height={20}
width={80}
height={80}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType?.[data.DamageType]?.Icon}`}
className="w-full h-full object-contain"
alt="Element"
src={`${process.env.CDN_URL}/${data.Image.ActionAvatarHeadIconPath}`}
priority={true}
className="w-full h-full object-contain transition-transform duration-200 group-hover:scale-105"
alt="Avatar"
/>
</div>
<div className="absolute top-0 right-0 w-5 h-5 bg-black/40 rounded-full flex items-center justify-center p-0.5">
<Image
width={20}
height={20}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${baseType?.[data.BaseType]?.Icon}`}
className="w-full h-full object-contain"
alt="Path"
/>
</div>
{damageType?.[data.DamageType]?.Icon && (
<div className="absolute top-0.5 left-0.5 w-4 h-4 sm:w-5 sm:h-5 bg-black/60 backdrop-blur-xs rounded-full flex items-center justify-center p-0.5 border border-white/10">
<Image
width={20}
height={20}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType[data.DamageType].Icon}`}
className="w-full h-full object-contain"
alt="Element"
/>
</div>
)}
{baseType?.[data.BaseType]?.Icon && (
<div className="absolute top-0.5 right-0.5 w-4 h-4 sm:w-5 sm:h-5 bg-black/60 backdrop-blur-xs rounded-full flex items-center justify-center p-0.5 border border-white/10">
<Image
width={20}
height={20}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${baseType[data.BaseType].Icon}`}
className="w-full h-full object-contain"
alt="Path"
/>
</div>
)}
{showRemoveHover && (
<div className="absolute inset-0 bg-error/80 rounded-md flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
{showRemoveHover && (
<div className="absolute inset-0 bg-error/90 backdrop-blur-xs rounded-xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<X className="h-6 w-6 text-white" />
</div>
)}
</div>
</div>
);
};
+37 -42
View File
@@ -1,33 +1,35 @@
import useLocaleStore from '@/stores/localeStore';
import { Check, Sparkles, Bug, Zap, Package, Calendar } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Sparkles, Bug, Zap, Package, Calendar } from 'lucide-react';
export default function ChangelogBar() {
const { changelog } = useLocaleStore()
const { changelog } = useLocaleStore(
useShallow((state) => ({ changelog: state.changelog }))
)
const getIcon = (type: string) => {
switch (type) {
case 'feature':
return <Sparkles className="w-3 h-3 md:w-4 md:h-4" />;
return <Sparkles className="w-3.5 h-3.5" />;
case 'fix':
return <Bug className="w-3 h-3 md:w-4 md:h-4" />;
return <Bug className="w-3.5 h-3.5" />;
case 'improvement':
return <Zap className="w-3 h-3 md:w-4 md:h-4" />;
return <Zap className="w-3.5 h-3.5" />;
default:
return <Package className="w-3 h-3 md:w-4 md:h-4" />;
return <Package className="w-3.5 h-3.5" />;
}
};
const getBadgeClass = (type: string) => {
switch (type) {
case 'feature':
return 'badge-success';
return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
case 'fix':
return 'badge-error';
return 'bg-rose-500/15 text-rose-400 border-rose-500/30';
case 'improvement':
return 'badge-warning';
return 'bg-amber-500/15 text-amber-400 border-amber-500/30';
default:
return 'badge-info';
return 'bg-primary/15 text-primary border-primary/30';
}
};
@@ -45,44 +47,37 @@ export default function ChangelogBar() {
};
return (
<div className="md:p-4">
{/* Alert */}
{/* <div className="alert alert-info mb-4 md:p-2 p-1">
<AlertCircle className="w-6 h-6" />
</div> */}
{/* Timeline */}
<div className="flex flex-col gap-4">
<div className="space-y-4">
<div className="space-y-3">
{changelog.map((change, index) => (
<div key={index} className="bg-base-100 shadow-sm">
<div className="flex flex-col gap-2">
{/* Version Header */}
<div className="flex flex-row items-center gap-2 md:gap-3">
<div className={`badge ${getBadgeClass(change.type)} gap-1 md:gap-2 p-1`}>
<div key={index} className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-3">
{/* Version Header */}
<div className="flex flex-wrap items-center justify-between gap-2 pb-2.5 border-b border-base-content/10">
<div className="flex items-center gap-2">
<span className={`px-2.5 py-1 rounded-full text-xs font-mono font-bold flex items-center gap-1.5 border ${getBadgeClass(change.type)}`}>
{getIcon(change.type)}
{getTypeLabel(change.type)}
</div>
<h2 className="card-title text-sm md:text-lg">
</span>
<h3 className="font-extrabold text-sm sm:text-base tracking-tight">
Version {change.version}
</h2>
<div className="flex items-center gap-1 md:gap-2 text-base-content/60 ml-auto">
<Calendar className="w-3 h-3 md:w-4 md:h-4" />
<span className="text-sm">{change.date}</span>
</div>
</h3>
</div>
{/* Changes List */}
<ul className="space-y-3">
{change.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex items-start gap-3">
<div className="mt-1">
<Check className="w-5 h-5 text-success" />
</div>
<span className="text-base-content">{item}</span>
</li>
))}
</ul>
<div className="flex items-center gap-1.5 text-xs text-base-content/50 font-mono">
<Calendar className="w-3.5 h-3.5" />
<span>{change.date}</span>
</div>
</div>
{/* Changes List */}
<ul className="space-y-2 pt-1 text-xs sm:text-sm text-base-content/85">
{change.items.map((item, itemIndex) => (
<li key={itemIndex} className="flex items-start gap-2.5">
<div className="mt-1 w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
<span className="leading-relaxed">{item}</span>
</li>
))}
</ul>
</div>
))}
</div>
+156 -122
View File
@@ -6,6 +6,8 @@ import useGlobalStore from "@/stores/globalStore"
import { PSConnectType } from "@/types"
import { useTranslations } from "next-intl"
import { useState } from "react"
import { useShallow } from "zustand/react/shallow"
import { Server, RefreshCw, AlertCircle } from "lucide-react"
export default function ConnectBar() {
const transI18n = useTranslations("DataPage")
@@ -21,149 +23,181 @@ export default function ConnectBar() {
setServerUrl,
setUsername,
setPassword
} = useConnectStore()
const { isConnectPS, setIsConnectPS } = useGlobalStore()
} = useConnectStore(
useShallow((state) => ({
connectionType: state.connectionType,
privateType: state.privateType,
serverUrl: state.serverUrl,
username: state.username,
password: state.password,
setConnectionType: state.setConnectionType,
setPrivateType: state.setPrivateType,
setServerUrl: state.setServerUrl,
setUsername: state.setUsername,
setPassword: state.setPassword,
}))
)
const { isConnectPS, setIsConnectPS } = useGlobalStore(
useShallow((state) => ({
isConnectPS: state.isConnectPS,
setIsConnectPS: state.setIsConnectPS,
}))
)
return (
<div className="px-6 py-4">
<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) => {
setIsConnectPS(false)
setConnectionType(e.target.value)
}}
>
<option value={PSConnectType.FireflyGo}>FireflyGo</option>
<option value={PSConnectType.RobinSR}>RobinSR</option>
<option value={PSConnectType.Other}>{transI18n("other")}</option>
</select>
</div>
{connectionType === PSConnectType.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">
<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,
});
}
<div className="space-y-4">
<div className="p-4 sm:p-6 rounded-2xl bg-base-200/60 border border-white/10 space-y-4">
{/* Connection Type Selection */}
<div className="space-y-1.5">
<label className="block text-xs font-bold uppercase tracking-wider text-base-content/70">
{transI18n("connectionType")}
</label>
<select
className="select select-bordered w-full rounded-xl bg-base-100 border-base-content/10 focus:border-primary text-sm font-medium"
value={connectionType}
onChange={(e) => {
setIsConnectPS(false)
setConnectionType(e.target.value)
}}
className="btn btn-primary w-full sm:w-auto"
>
{transI18n("connectPs")}
</button>
<option value={PSConnectType.FireflyGo}>FireflyGo</option>
<option value={PSConnectType.RobinSR}>RobinSR</option>
<option value={PSConnectType.Other}>{transI18n("other")}</option>
</select>
</div>
{isConnectPS && (
{connectionType === PSConnectType.Other && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
<div className="space-y-1">
<label className="block text-xs font-semibold text-base-content/70">
{transI18n("serverUrl")}
</label>
<input
type="text"
placeholder={transI18n("placeholderServerUrl")}
className="input input-sm sm:input-md input-bordered w-full rounded-xl bg-base-100 border-base-content/10 focus:border-primary text-xs sm:text-sm"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-base-content/70">
{transI18n("privateType")}
</label>
<select
className="select select-sm sm:select-md select-bordered w-full rounded-xl bg-base-100 border-base-content/10 focus:border-primary text-xs sm:text-sm"
value={privateType}
onChange={(e) => setPrivateType(e.target.value)}
>
<option value="Local">{transI18n("local")}</option>
<option value="Server">{transI18n("server")}</option>
</select>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-base-content/70">
{transI18n("username")}
</label>
<input
type="text"
placeholder={transI18n("placeholderUsername")}
className="input input-sm sm:input-md input-bordered w-full rounded-xl bg-base-100 border-base-content/10 focus:border-primary text-xs sm:text-sm"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-base-content/70">
{transI18n("password")}
</label>
<input
type="password"
placeholder={transI18n("placeholderPassword")}
className="input input-sm sm:input-md input-bordered w-full rounded-xl bg-base-100 border-base-content/10 focus:border-primary text-xs sm:text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
)}
{/* Status Message Notification */}
{message.text && (
<div className={`p-3 rounded-xl border text-xs flex items-center gap-2 ${
message.type === 'success'
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
: message.type === 'error'
? 'bg-rose-500/10 border-rose-500/30 text-rose-400'
: 'bg-info/10 border-info/30 text-info'
}`}>
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{message.text}</span>
</div>
)}
{/* Footer Controls */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 pt-3 border-t border-base-content/10">
<div className="flex items-center gap-2">
<span className="text-xs text-base-content/60">{transI18n("status")}:</span>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-mono font-bold border ${
isConnectPS
? "bg-emerald-500/15 border-emerald-500/30 text-emerald-400"
: "bg-rose-500/15 border-rose-500/30 text-rose-400"
}`}>
<span className={`w-2 h-2 rounded-full ${isConnectPS ? 'bg-emerald-400 animate-pulse' : 'bg-rose-400'}`}></span>
<span>{isConnectPS ? transI18n("connected") : transI18n("unconnected")}</span>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<button
onClick={async () => {
const response = await syncDataToPS();
const response = await connectToPS();
if (response.success) {
setMessage({
type: "success",
text: transI18n("syncSuccess"),
text: transI18n("connectedSuccess"),
});
} else {
setMessage({
type: "error",
text: `${transI18n("syncFailed")}: ${response.message}`,
text: response.message,
});
}
}}
className="btn btn-success w-full sm:w-auto"
className="btn btn-sm btn-primary rounded-xl font-bold flex-1 sm:flex-none shadow-xs"
>
{transI18n("sync")}
<Server className="w-3.5 h-3.5" />
{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-sm btn-accent rounded-xl font-bold flex-1 sm:flex-none shadow-xs"
>
<RefreshCw className="w-3.5 h-3.5" />
{transI18n("sync")}
</button>
)}
</div>
</div>
</div>
</div>
)
}
+101 -44
View File
@@ -8,13 +8,22 @@ import { useMemo } from "react";
import { useTranslations } from "next-intl";
import useCurrentDataStore from "@/stores/currentDataStore";
import ExtraEffectList from '../extraInfo';
import { useShallow } from "zustand/react/shallow";
export default function EidolonsInfo() {
const { avatarSelected } = useCurrentDataStore()
const { locale } = useLocaleStore()
const { avatarSelected } = useCurrentDataStore(
useShallow((state) => ({ avatarSelected: state.avatarSelected }))
)
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const transI18n = useTranslations("DataPage")
const { setAvatars, avatars } = useUserDataStore()
const { setAvatars, avatars } = useUserDataStore(
useShallow((state) => ({
setAvatars: state.setAvatars,
avatars: state.avatars,
}))
)
const charRank = useMemo(() => {
if (!avatarSelected) return null;
@@ -25,58 +34,106 @@ export default function EidolonsInfo() {
return avatarSelected?.Ranks
}, [avatarSelected, avatars]);
const currentRank = avatars[avatarSelected?.ID.toString() || ""]?.data?.rank || 0;
const handleToggleRank = (rankNum: number) => {
if (!avatarSelected) return;
let newRank = rankNum;
if (currentRank === rankNum) {
newRank = rankNum - 1;
}
setAvatars({
...avatars,
[avatarSelected.ID]: {
...avatars[avatarSelected.ID],
data: {
...avatars[avatarSelected.ID]?.data,
rank: newRank,
},
},
});
};
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">
<div className="w-full">
<div className="bg-base-100 rounded-2xl p-4 sm:p-6 border border-base-300 shadow-sm space-y-6">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-base-300">
<h2 className="text-lg sm:text-xl font-bold flex items-center gap-2.5">
<div className="w-1.5 h-5 bg-linear-to-b from-primary to-accent rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-primary to-accent font-bold tracking-tight">
{transI18n("eidolons")}
</span>
</h2>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-base-200 border border-base-300 text-xs font-mono font-bold">
<span className="text-base-content/60">Active:</span>
<span className="text-primary text-sm font-extrabold">E{currentRank}</span>
</div>
</div>
{/* 6 Eidolons Organic Grid */}
{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"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 lg:gap-8">
{Object.entries(charRank || {}).map(([key, rank]) => {
const rankNum = Number(rank.Rank);
const isUnlocked = rankNum <= currentRank;
return (
<div
className="cursor-pointer"
onClick={() => {
let newRank = Number(rank.Rank)
if (avatars[avatarSelected?.ID || ""]?.data?.rank == Number(rank.Rank)) {
newRank = Number(rank.Rank) - 1
}
setAvatars({ ...avatars, [avatarSelected?.ID || ""]: { ...avatars[avatarSelected?.ID || ""], data: { ...avatars[avatarSelected?.ID || ""].data, rank: newRank } } })
}}
key={key}
className="flex flex-col items-center text-center space-y-3 p-2"
>
<Image
className={`w-60 object-contain mb-2 ${Number(rank.Rank) <= avatars[avatarSelected?.ID.toString() || ""]?.data?.rank ? "" : "grayscale"}`}
src={`${process.env.CDN_URL}/ui/ui3d/rank/_dependencies/textures/${avatarSelected?.ID}/${avatarSelected?.ID}_Rank_${rank.Rank}.png`}
alt={`Rank ${rank.Rank} Image`}
priority
unoptimized
crossOrigin="anonymous"
width={240}
height={240}
/>
{/* 1. Organic Floating Eidolon Shard (No artificial rectangle wrapper) */}
<div
onClick={() => handleToggleRank(rankNum)}
className="cursor-pointer select-none transition-transform duration-200 hover:scale-105 active:scale-95"
title={`Click to ${isUnlocked ? 'deactivate' : 'activate'} E${rank.Rank}`}
>
<Image
className={`w-44 sm:w-52 h-auto object-contain drop-shadow-md transition-all duration-300 ${
isUnlocked
? "drop-shadow-[0_0_12px_rgba(var(--color-primary),0.35)]"
: "grayscale opacity-40 hover:opacity-70"
}`}
src={`${process.env.CDN_URL}/ui/ui3d/rank/_dependencies/textures/${avatarSelected?.ID}/${avatarSelected?.ID}_rank_${rank.Rank}.png`}
alt={`Rank ${rank.Rank} Image`}
priority
unoptimized
crossOrigin="anonymous"
width={240}
height={240}
/>
</div>
<div className="text-lg pb-1 flex items-center justify-items-center gap-2">
<span className="inline-block text-indigo-500">{rank.Rank}.</span>
{/* 2. Eidolon Name & Rank */}
<div
onClick={() => handleToggleRank(rankNum)}
className="flex items-center justify-center gap-1.5 cursor-pointer group select-none"
>
<span className={`text-base font-extrabold font-mono ${
isUnlocked ? "text-primary" : "text-base-content/50"
}`}>
E{rank.Rank}.
</span>
<ParseText
locale={locale}
text={getLocaleName(locale, rank.Name)}
className="text-center text-base font-normal leading-tight"
className="font-bold text-base text-base-content group-hover:text-primary transition-colors"
/>
</div>
</div>
<div className="text-sm font-normal">
<div dangerouslySetInnerHTML={{ __html: replaceByParam(getLocaleName(locale, rank.Desc), rank.Param) }} />
<ExtraEffectList extras={rank?.Extra} locale={locale} />
{/* 3. Description & Extra Effects (Direct readable text, fully selectable & copyable) */}
<div className="w-full text-left text-xs sm:text-sm text-base-content/85 leading-relaxed space-y-2 select-text pt-1 border-t border-base-300/60">
<div
dangerouslySetInnerHTML={{
__html: replaceByParam(getLocaleName(locale, rank.Desc), rank.Param),
}}
/>
<ExtraEffectList extras={rank?.Extra} locale={locale} />
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>
+410 -396
View File
@@ -1,5 +1,4 @@
'use client'
import { motion } from "framer-motion"
import { EyeOff, Eye, Hammer, RefreshCw, ShieldBan, User, Swords, SkipForward, BowArrow, Info, RouteIcon, Search, CupSoda, UsersIcon } from "lucide-react"
import useGlobalStore from '@/stores/globalStore'
import { useTranslations } from "next-intl"
@@ -13,12 +12,31 @@ import Prism from "prismjs"
import "prismjs/components/prism-lua"
import "prismjs/themes/prism-tomorrow.css"
import { SimpleAvatarCard } from "../card/simpleCharacterCard"
import { useShallow } from "zustand/react/shallow"
export default function ExtraSettingBar() {
const { extraData, setExtraData, isEnableChangePath, setIsEnableChangePath, isEnableLua, setIsEnableLua } = useGlobalStore()
const { extraData, setExtraData, isEnableChangePath, setIsEnableChangePath, isEnableLua, setIsEnableLua } = useGlobalStore(
useShallow((state) => ({
extraData: state.extraData,
setExtraData: state.setExtraData,
isEnableChangePath: state.isEnableChangePath,
setIsEnableChangePath: state.setIsEnableChangePath,
isEnableLua: state.isEnableLua,
setIsEnableLua: state.setIsEnableLua,
}))
)
const transI18n = useTranslations("DataPage")
const { mapAvatar, mapPeak, stage, baseType } = useDetailDataStore()
const { locale } = useLocaleStore()
const { mapAvatar, mapPeak, stage, baseType } = useDetailDataStore(
useShallow((state) => ({
mapAvatar: state.mapAvatar,
mapPeak: state.mapPeak,
stage: state.stage,
baseType: state.baseType,
}))
)
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const [showSearchStage, setShowSearchStage] = useState(false)
const [showSearchLineup, setShowSearchLineup] = useState(false)
const [isChildClick, setIsChildClick] = useState(false)
@@ -48,200 +66,242 @@ export default function ExtraSettingBar() {
}
return (
<div className="px-4 sm:px-6 py-4 space-y-8">
<div className="space-y-6">
{/* THEORY CRAFT */}
{extraData?.theory_craft && (
<div className="space-y-4">
<h3 className="text-lg font-bold flex items-center gap-2">
{transI18n("theoryCraft")}
<div className="tooltip tooltip-info tooltip-bottom" data-tip={transI18n("detailTheoryCraft")}>
<Info className="text-primary" size={20} />
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-base-content/10">
<h3 className="text-sm font-bold uppercase tracking-wider text-primary flex items-center gap-2">
<span className="w-1.5 h-3.5 bg-primary rounded-full"></span>
{transI18n("theoryCraft")}
</h3>
<div className="tooltip tooltip-left tooltip-info" data-tip={transI18n("detailTheoryCraft")}>
<Info className="text-primary w-4 h-4 cursor-pointer" />
</div>
</h3>
</div>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex flex-wrap items-center 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: {
hp: extraData?.theory_craft?.hp || {},
cycle_count: extraData?.theory_craft?.cycle_count || 1,
mode: e.target.checked,
stage_id: extraData?.theory_craft?.stage_id || 0
}
})
}
}
/>
<span className="label-text font-semibold">{transI18n("theoryCraftMode")}</span>
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between">
<label className="flex items-center gap-3 cursor-pointer">
<Hammer className="text-primary w-4 h-4" />
<span className="text-xs sm:text-sm font-semibold">{transI18n("theoryCraftMode")}</span>
</label>
</motion.div>
<input
type="checkbox"
className="toggle toggle-primary"
checked={extraData?.theory_craft?.mode}
onChange={(e) => {
setExtraData({
...extraData,
theory_craft: {
hp: extraData?.theory_craft?.hp || {},
cycle_count: extraData?.theory_craft?.cycle_count || 1,
mode: e.target.checked,
stage_id: extraData?.theory_craft?.stage_id || 0
}
})
}}
/>
</div>
{extraData?.theory_craft?.mode && (
<>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex flex-wrap items-center 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-primary"
value={extraData?.theory_craft?.cycle_count}
onChange={(e) =>
setExtraData({
...extraData,
theory_craft: {
hp: extraData?.theory_craft?.hp || {},
cycle_count: Number(e.target.value) || 1,
mode: extraData?.theory_craft?.mode || false,
stage_id: extraData?.theory_craft?.stage_id || 0
}
})
}
min="1"
/>
</label>
</motion.div>
<div className="space-y-3 pt-1">
{/* Cycle Count */}
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<RefreshCw className="text-info w-4 h-4" />
<span className="text-xs sm:text-sm font-semibold">{transI18n("cycleCount")}</span>
</div>
<input
type="number"
className="input input-sm input-bordered w-24 rounded-xl text-center font-mono font-bold"
value={extraData?.theory_craft?.cycle_count}
onChange={(e) =>
setExtraData({
...extraData,
theory_craft: {
hp: extraData?.theory_craft?.hp || {},
cycle_count: Number(e.target.value) || 1,
mode: extraData?.theory_craft?.mode || false,
stage_id: extraData?.theory_craft?.stage_id || 0
}
})
}
min="1"
/>
</div>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex flex-wrap items-center justify-start gap-3">
<RouteIcon className="text-info" size={20} />
<span className="label-text font-semibold">{transI18n("stage")}</span>
<div className="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()
if (!isChildClick) {
setShowSearchStage(pre => !pre)
}
setIsChildClick(false)
}}
>
<Search className="w-6 h-6" />
<span className="text-left"> {transI18n("stage")}: {getLocaleName(locale, stageList.find((s) => s.ID.toString() === extraData?.theory_craft?.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">
{/* Stage Selection */}
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2">
<div className="flex items-center gap-2">
<RouteIcon className="text-info w-4 h-4" />
<span className="text-xs sm:text-sm font-semibold">{transI18n("stage")}</span>
</div>
<div className="relative">
<button
className="btn btn-sm btn-outline w-full rounded-xl flex items-center justify-between text-xs sm:text-sm"
onClick={(e) => {
e.stopPropagation()
if (!isChildClick) {
setShowSearchStage(pre => !pre)
}
setIsChildClick(false)
}}
>
<span className="truncate">
{getLocaleName(locale, stageList.find((s) => s.ID.toString() === extraData?.theory_craft?.stage_id?.toString())?.Name) || transI18n("selectStage")}
</span>
<Search className="w-3.5 h-3.5 shrink-0 ml-2" />
</button>
<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) => onChangeSearch(e.target.value)}
autoFocus
/>
{showSearchStage && (
<div onClick={(e) => e.stopPropagation()} className="absolute top-full mt-2 w-full z-50 bg-base-100 border border-base-content/15 rounded-2xl p-3.5 shadow-2xl space-y-3">
<input
type="search"
className="input input-sm input-bordered w-full rounded-xl text-xs"
placeholder={transI18n("searchStage")}
value={stageSearchTerm}
onChange={(e) => onChangeSearch(e.target.value)}
autoFocus
/>
</label>
</div>
<div className="max-h-56 overflow-y-auto space-y-1 pr-1 text-xs">
{paginatedStages.length > 0 ? (
paginatedStages.map((stage) => (
<div
key={stage.ID}
className="p-2 hover:bg-primary/20 rounded-xl cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
setIsChildClick(true)
<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={(e) => {
e.stopPropagation()
setIsChildClick(true)
if (extraData?.theory_craft?.stage_id !== Number(stage.ID)) {
setExtraData({
...extraData,
theory_craft: {
stage_id: Number(stage.ID),
cycle_count: extraData?.theory_craft?.cycle_count ?? 1,
mode: extraData?.theory_craft?.mode ?? false,
hp: extraData?.theory_craft?.hp ?? {},
custom_lineup: extraData?.theory_craft?.custom_lineup ?? []
}
})
if (extraData?.theory_craft?.stage_id !== Number(stage.ID)) {
setExtraData({
...extraData,
theory_craft: {
stage_id: Number(stage.ID),
cycle_count: extraData?.theory_craft?.cycle_count ?? 1,
mode: extraData?.theory_craft?.mode ?? false,
hp: extraData?.theory_craft?.hp ?? {},
custom_lineup: extraData?.theory_craft?.custom_lineup ?? []
}
setShowSearchStage(pre => !pre)
onChangeSearch("")
}}
>
{getLocaleName(locale, 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)
})
}
setShowSearchStage(pre => !pre)
onChangeSearch("")
}}
>
{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>
{getLocaleName(locale, stage.Name)}
</div>
))
) : (
<div className="text-xs text-center py-4 text-base-content/40">{transI18n("noStageFound")}</div>
)}
</div>
)}
</div>
</label>
</motion.div>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow relative">
<div className="flex flex-col gap-3">
<label className="flex flex-wrap items-center justify-start gap-3">
<UsersIcon className="text-info" size={20} />
<span className="label-text font-semibold">{transI18n("customLineup")}</span>
</label>
<div className="flex flex-wrap items-center gap-2 min-h-20 p-2 bg-base-300 rounded-lg border border-base-content/10">
{(extraData?.theory_craft?.custom_lineup || []).length > 0 && (
(extraData?.theory_craft?.custom_lineup || []).map((avatarId) => {
const avatarData = mapAvatar[avatarId];
if (!avatarData) return null;
{paginatedStages.length > 0 && (
<div className="flex items-center justify-between gap-2 pt-2 border-t border-white/5">
<button
disabled={stagePage === 1}
className="btn btn-xs btn-outline rounded-lg"
onClick={(e) => {
e.stopPropagation()
setStagePage(stagePage - 1)
}}
>
{transI18n("previous")}
</button>
<span className="text-[11px] font-mono text-base-content/50">Page {stagePage}</span>
<button
disabled={!hasMorePages}
className="btn btn-xs btn-outline rounded-lg"
onClick={(e) => {
e.stopPropagation()
setStagePage(stagePage + 1)
}}
>
{transI18n("next")}
</button>
</div>
)}
</div>
)}
</div>
</div>
{/* Custom Lineup */}
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2.5">
<div className="flex items-center gap-2">
<UsersIcon className="text-info w-4 h-4" />
<span className="text-xs sm:text-sm font-semibold">{transI18n("customLineup")}</span>
</div>
<div className="flex flex-wrap items-center gap-2 min-h-16 p-2 bg-base-200/50 rounded-xl border border-white/5">
{(extraData?.theory_craft?.custom_lineup || []).map((avatarId) => {
const avatarData = mapAvatar[avatarId];
if (!avatarData) return null;
return (
<SimpleAvatarCard
key={`selected-${avatarId}`}
data={avatarData}
showRemoveHover={true}
onClick={() => {
const newLineup = (extraData?.theory_craft?.custom_lineup || []).filter(id => id !== avatarId);
setExtraData({
...extraData,
theory_craft: {
stage_id: extraData?.theory_craft?.stage_id ?? 30118121,
cycle_count: extraData?.theory_craft?.cycle_count ?? 1,
mode: extraData?.theory_craft?.mode ?? false,
hp: extraData?.theory_craft?.hp ?? {},
custom_lineup: newLineup
}
});
}}
/>
);
})}
<button
className="btn btn-outline btn-square border-dashed w-14 h-14 sm:w-16 sm:h-16 rounded-xl"
onClick={(e) => {
e.stopPropagation();
setShowSearchLineup(pre => !pre);
}}
>
+
</button>
</div>
{showSearchLineup && (
<div
onClick={(e) => e.stopPropagation()}
className="mt-2 w-full z-50 bg-base-100 border border-base-content/15 rounded-2xl p-4 shadow-2xl space-y-3"
>
<div className="flex justify-between items-center pb-2 border-b border-base-content/10">
<span className="font-bold text-xs">{transI18n("selectedCharacters")}</span>
<button className="btn btn-xs btn-circle btn-ghost" onClick={() => setShowSearchLineup(false)}>✕</button>
</div>
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto p-1">
{Object.values(mapAvatar).map((avatar) => {
const currentLineup = extraData?.theory_craft?.custom_lineup || [];
const isSelected = currentLineup.includes(avatar.ID.toString());
return (
<SimpleAvatarCard
key={`selected-${avatarId}`}
data={avatarData}
showRemoveHover={true}
key={avatar.ID}
data={avatar}
isSelected={isSelected}
onClick={() => {
const newLineup = (extraData?.theory_craft?.custom_lineup || []).filter(id => id !== avatarId);
const idStr = avatar.ID.toString();
let newLineup = [...currentLineup];
if (isSelected) {
newLineup = newLineup.filter(id => id !== idStr);
} else {
newLineup.push(idStr);
}
setExtraData({
...extraData,
theory_craft: {
@@ -255,80 +315,26 @@ export default function ExtraSettingBar() {
}}
/>
);
})
)}
<button
className="btn btn-outline btn-square border-dashed w-16 h-16 sm:w-20 sm:h-20"
onClick={(e) => {
e.stopPropagation();
setShowSearchLineup(pre => !pre);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" /></svg>
</button>
</div>
{showSearchLineup && (
<div
onClick={(e) => e.stopPropagation()}
className="mt-2 w-full z-50 border bg-base-200 border-slate-600 rounded-lg p-4 shadow-2xl"
>
<div className="flex justify-between items-center mb-3">
<span className="font-semibold text-sm">{transI18n("selectedCharacters")}</span>
<button className="btn btn-sm btn-circle btn-error" onClick={() => setShowSearchLineup(false)}>X</button>
</div>
<div className="flex flex-wrap gap-2 max-h-72 overflow-y-auto p-1">
{Object.values(mapAvatar).map((avatar) => {
const currentLineup = extraData?.theory_craft?.custom_lineup || [];
const isSelected = currentLineup.includes(avatar.ID.toString());
return (
<SimpleAvatarCard
key={avatar.ID}
data={avatar}
isSelected={isSelected}
onClick={() => {
const idStr = avatar.ID.toString();
let newLineup = [...currentLineup];
if (isSelected) {
newLineup = newLineup.filter(id => id !== idStr);
} else {
newLineup.push(idStr);
}
setExtraData({
...extraData,
theory_craft: {
stage_id: extraData?.theory_craft?.stage_id ?? 30118121,
cycle_count: extraData?.theory_craft?.cycle_count ?? 1,
mode: extraData?.theory_craft?.mode ?? false,
hp: extraData?.theory_craft?.hp ?? {},
custom_lineup: newLineup
}
});
}}
/>
);
})}
</div>
})}
</div>
)}
</div>
</motion.div>
</>
</div>
)}
</div>
</div>
)}
</div>
)}
{/*MULTIPATH CHAR */}
<div className="space-y-4">
<h3 className="text-lg font-bold flex items-center gap-2">
{transI18n("multipathCharacter")}
<div className="tooltip tooltip-info tooltip-bottom" data-tip={transI18n("detailMultipathCharacter")}>
<Info className="text-primary" size={20} />
{/* MULTIPATH CHAR */}
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-base-content/10">
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold uppercase tracking-wider text-primary">
{transI18n("multipathCharacter")}
</h3>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailMultipathCharacter")}>
<Info className="text-primary w-4 h-4 cursor-pointer" />
</div>
</div>
<input
type="checkbox"
@@ -336,87 +342,95 @@ export default function ExtraSettingBar() {
checked={isEnableChangePath}
onChange={(e) => setIsEnableChangePath(e.target.checked)}
/>
</h3>
</div>
{isEnableChangePath && (
<>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex items-center gap-3">
<User className="text-warning" size={20} />
<span className="label-text font-semibold">{transI18n("mainPath")}</span>
<SelectCustomImage
customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_main?.includes(Number(it.ID))).map((it) => ({
value: it.ID.toString(),
label: getNameChar(locale, transI18n, it),
imageUrl: `${process.env.CDN_URL}/${baseType?.[it.BaseType].Icon}`
}))}
excludeSet={[]}
selectedCustomSet={extraData?.multi_path?.main?.toString() || ""}
placeholder={transI18n("selectAMainStat")}
setSelectedCustomSet={(it) => {
if (!it) return
setExtraData({
...extraData,
multi_path: {
march_7: extraData?.multi_path?.march_7 || 1001,
main: Number(it) || 8001,
multi_path_main: extraData?.multi_path?.multi_path_main || [],
multi_path_march_7: extraData?.multi_path?.multi_path_march_7 || []
},
lua: extraData?.lua || null
})
}}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2">
<div className="flex items-center gap-2">
<User className="text-warning w-4 h-4" />
<span className="text-xs font-semibold">{transI18n("mainPath")}</span>
</div>
<SelectCustomImage
customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_main?.includes(Number(it.ID))).map((it) => ({
value: it.ID.toString(),
label: getNameChar(locale, transI18n, it),
imageUrl: `${process.env.CDN_URL}/${baseType?.[it.BaseType]?.Icon}`
}))}
excludeSet={[]}
selectedCustomSet={extraData?.multi_path?.main?.toString() || ""}
placeholder={transI18n("selectAMainStat")}
setSelectedCustomSet={(it) => {
if (!it) return
setExtraData({
...extraData,
multi_path: {
march_7: extraData?.multi_path?.march_7 || 1001,
main: Number(it) || 8001,
multi_path_main: extraData?.multi_path?.multi_path_main || [],
multi_path_march_7: extraData?.multi_path?.multi_path_march_7 || []
},
lua: extraData?.lua || null
})
}}
/>
</div>
</label>
</motion.div>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex items-center gap-3">
<BowArrow className="text-info" size={20} />
<span className="label-text font-semibold">{transI18n("march7Path")}</span>
<SelectCustomImage
customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_march_7?.includes(Number(it.ID))).map((it) => ({
value: it.ID.toString(),
label: getNameChar(locale, transI18n, it),
imageUrl: `${process.env.CDN_URL}/${baseType?.[it.BaseType].Icon}`
}))}
excludeSet={[]}
selectedCustomSet={extraData?.multi_path?.march_7?.toString() || ""}
placeholder={transI18n("selectAMainStat")}
setSelectedCustomSet={(it) => {
if (!it) return
setExtraData({
...extraData,
multi_path: {
march_7: Number(it) || 1001,
main: extraData?.multi_path?.main || 8001,
multi_path_main: extraData?.multi_path?.multi_path_main || [],
multi_path_march_7: extraData?.multi_path?.multi_path_march_7 || []
},
lua: extraData?.lua || null
})
}}
/>
</label>
</motion.div>
</>
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2">
<div className="flex items-center gap-2">
<BowArrow className="text-info w-4 h-4" />
<span className="text-xs font-semibold">{transI18n("march7Path")}</span>
</div>
<SelectCustomImage
customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_march_7?.includes(Number(it.ID))).map((it) => ({
value: it.ID.toString(),
label: getNameChar(locale, transI18n, it),
imageUrl: `${process.env.CDN_URL}/${baseType?.[it.BaseType]?.Icon}`
}))}
excludeSet={[]}
selectedCustomSet={extraData?.multi_path?.march_7?.toString() || ""}
placeholder={transI18n("selectAMainStat")}
setSelectedCustomSet={(it) => {
if (!it) return
setExtraData({
...extraData,
multi_path: {
march_7: Number(it) || 1001,
main: extraData?.multi_path?.main || 8001,
multi_path_main: extraData?.multi_path?.multi_path_main || [],
multi_path_march_7: extraData?.multi_path?.multi_path_march_7 || []
},
lua: extraData?.lua || null
})
}}
/>
</div>
</div>
)}
</div>
{/* CHALLENGE */}
<div className="space-y-4">
<h3 className="text-lg font-bold">{transI18n("challenge")}</h3>
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4">
<div className="pb-2 border-b border-base-content/10">
<h3 className="text-sm font-bold uppercase tracking-wider text-primary">
{transI18n("challenge")}
</h3>
</div>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex items-center gap-3">
<Swords className="text-error" size={20} />
<span className="label-text font-semibold">{transI18n("anomalyArbitration")}</span>
<div className="tooltip tooltip-info tooltip-bottom" data-tip={transI18n("detailChallengePeak")}>
<Info className="text-primary" size={20} />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Swords className="text-error w-4 h-4" />
<span className="text-xs font-semibold">{transI18n("anomalyArbitration")}</span>
</div>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailChallengePeak")}>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" />
</div>
</div>
<select
value={extraData?.challenge?.challenge_peak_group_id || ""}
className="select select-error"
className="select select-sm select-bordered w-full rounded-xl text-xs"
onChange={(e) =>
setExtraData({
...extraData,
@@ -435,19 +449,21 @@ export default function ExtraSettingBar() {
</option>
))}
</select>
</label>
</motion.div>
</div>
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex items-center gap-3">
<SkipForward className="text-warning" size={20} />
<span className="label-text font-semibold">{transI18n("skipNode")}</span>
<div className="tooltip tooltip-info tooltip-bottom" data-tip={transI18n("detailSkipNode")}>
<Info className="text-primary" size={20} />
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SkipForward className="text-warning w-4 h-4" />
<span className="text-xs font-semibold">{transI18n("skipNode")}</span>
</div>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailSkipNode")}>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" />
</div>
</div>
<select
value={extraData?.challenge?.skip_node || "0"}
className="select select-warning"
className="select select-sm select-bordered w-full rounded-xl text-xs"
onChange={(e) =>
setExtraData({
...extraData,
@@ -464,25 +480,35 @@ export default function ExtraSettingBar() {
<option value="1">{transI18n("skipNode1")}</option>
<option value="2">{transI18n("skipNode2")}</option>
</select>
</label>
</motion.div>
</div>
</div>
</div>
{/*EXTRA FEATURES */}
<div className="space-y-4">
<h3 className="text-lg font-bold">{transI18n("extraFeatures")}</h3>
{/* EXTRA FEATURES */}
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4">
<div className="pb-2 border-b border-base-content/10">
<h3 className="text-sm font-bold uppercase tracking-wider text-primary">
{transI18n("extraFeatures")}
</h3>
</div>
{extraData?.setting?.hide_ui !== undefined && (
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex flex-wrap items-center cursor-pointer justify-start gap-3">
{extraData?.setting?.hide_ui
? <EyeOff className="text-warning" size={20} />
: <Eye className="text-success" size={20} />
}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{extraData?.setting?.hide_ui !== undefined && (
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2.5">
{extraData?.setting?.hide_ui
? <EyeOff className="text-warning w-4 h-4" />
: <Eye className="text-success w-4 h-4" />
}
<span className="text-xs font-semibold">{transI18n("hideUI")}</span>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailHiddenUi")}>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" />
</div>
</div>
<input
type="checkbox"
className="toggle toggle-secondary"
className="toggle toggle-primary toggle-sm"
checked={extraData?.setting?.hide_ui}
onChange={(e) =>
setExtraData({
@@ -496,21 +522,22 @@ export default function ExtraSettingBar() {
})
}
/>
<span className="label-text font-semibold">{transI18n("hideUI")}</span>
<div className="tooltip tooltip-info tooltip-bottom" data-tip={transI18n("detailHiddenUi")}>
<Info className="text-primary" size={20} />
</div>
)}
{extraData?.setting?.censorship !== undefined && (
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<ShieldBan className="text-error w-4 h-4" />
<span className="text-xs font-semibold">{transI18n("disableCensorship")}</span>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailDisableCensorship")}>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" />
</div>
</div>
</label>
</motion.div>
)}
{extraData?.setting?.censorship !== undefined && (
<motion.div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="flex flex-wrap items-center cursor-pointer justify-start gap-3">
<ShieldBan className="text-error" size={20} />
<input
type="checkbox"
className="toggle toggle-accent"
className="toggle toggle-accent toggle-sm"
checked={extraData?.setting?.censorship}
onChange={(e) =>
setExtraData({
@@ -524,20 +551,17 @@ export default function ExtraSettingBar() {
})
}
/>
<span className="label-text font-semibold">{transI18n("disableCensorship")}</span>
<div className="tooltip tooltip-info tooltip-bottom" data-tip={transI18n("detailDisableCensorship")}>
<Info className="text-primary" size={20} />
</div>
</label>
</motion.div>
)}
</div>
)}
</div>
</div>
<div className="space-y-4">
<h3 className="text-lg font-bold flex items-center gap-2">
{"Lua"}
<div className="tooltip tooltip-info tooltip-bottom">
<CupSoda className="text-primary" size={20} />
{/* LUA EDITOR */}
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4">
<div className="flex items-center justify-between pb-2 border-b border-base-content/10">
<div className="flex items-center gap-2">
<CupSoda className="text-primary w-4 h-4" />
<h3 className="text-sm font-bold uppercase tracking-wider text-primary">Lua Scripting</h3>
</div>
<input
type="checkbox"
@@ -545,40 +569,30 @@ export default function ExtraSettingBar() {
checked={isEnableLua}
onChange={(e) => setIsEnableLua(e.target.checked)}
/>
</h3>
{isEnableLua && (
<>
<div className="form-control bg-base-200 p-4 rounded-xl shadow">
<label className="label">
<span className="label-text">Lua Script</span>
</label>
</div>
<div className="rounded-xl overflow-hidden border border-base-300">
<Editor
value={extraData?.lua || ""}
onValueChange={(code) =>
setExtraData(
{
...extraData,
lua: code
}
)
}
highlight={(code) => Prism.highlight(code, Prism.languages.lua, "lua")}
padding={16}
textareaClassName="outline-none"
className="text-sm font-mono min-h-75"
style={{
background: "#1e1e1e",
color: "#fff",
}}
/>
</div>
</div>
</>
{isEnableLua && (
<div className="rounded-2xl overflow-hidden border border-white/10 shadow-inner">
<Editor
value={extraData?.lua || ""}
onValueChange={(code) =>
setExtraData({
...extraData,
lua: code
})
}
highlight={(code) => Prism.highlight(code, Prism.languages.lua, "lua")}
padding={16}
textareaClassName="outline-none font-mono"
className="text-xs font-mono min-h-60 bg-[#161822] text-white"
style={{
background: "#161822",
color: "#e2e8f0",
}}
/>
</div>
)}
</div>
</div>
)
}
+13 -4
View File
@@ -1,9 +1,18 @@
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()} - Firefly Shelter (Powered by Nextjs & DaisyUi)</p>
</aside>
<footer className="w-full border-t border-base-content/10 bg-base-100 py-6 px-4 mt-auto">
<div className="max-w-[1920px] mx-auto flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-base-content/60 font-medium">
<div className="flex items-center gap-2">
<span className="font-semibold text-base-content/80">Firefly Shelter</span>
<span>•</span>
<span>S Game Toolkit</span>
</div>
<div className="flex items-center gap-2">
<span>© {new Date().getFullYear()}</span>
<span>•</span>
<span className="px-2 py-0.5 rounded-full bg-base-200 text-base-content/70 border border-white/5">Next.js & DaisyUI</span>
</div>
</div>
</footer>
)
}
+314 -274
View File
@@ -6,7 +6,6 @@ import { useChangeTheme } from "@/hooks/useChangeTheme";
import { listCurrentLanguage, listCurrentLanguageApi } from "@/constant/constant";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -23,6 +22,7 @@ import ExtraSettingBar from "../extraSettingBar";
import ChangelogBar from "../changelog";
import { ModalConfig } from "@/types";
import { ScrollText } from "lucide-react";
import { useShallow } from "zustand/react/shallow";
const themes = [
{ label: "Winter" },
@@ -33,7 +33,12 @@ const themes = [
export default function Header() {
const { changeTheme } = useChangeTheme()
const { locale, setLocale } = useLocaleStore()
const { locale, setLocale } = useLocaleStore(
useShallow((state) => ({
locale: state.locale,
setLocale: state.setLocale,
}))
)
const {
avatars,
battle_type,
@@ -49,7 +54,24 @@ export default function Header() {
setAsConfig,
setCeConfig,
setPeakConfig,
} = useUserDataStore()
} = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
battle_type: state.battle_type,
setAvatars: state.setAvatars,
setBattleType: state.setBattleType,
moc_config: state.moc_config,
pf_config: state.pf_config,
as_config: state.as_config,
ce_config: state.ce_config,
peak_config: state.peak_config,
setMocConfig: state.setMocConfig,
setPfConfig: state.setPfConfig,
setAsConfig: state.setAsConfig,
setCeConfig: state.setCeConfig,
setPeakConfig: state.setPeakConfig,
}))
)
const router = useRouter()
const transI18n = useTranslations("DataPage")
@@ -64,11 +86,29 @@ export default function Header() {
isOpenExtra,
setIsChangelog,
isChangelog
} = useModelStore()
} = useModelStore(
useShallow((state) => ({
setIsOpenImport: state.setIsOpenImport,
isOpenImport: state.isOpenImport,
setIsOpenMonster: state.setIsOpenMonster,
isOpenMonster: state.isOpenMonster,
setIsOpenConnect: state.setIsOpenConnect,
isOpenConnect: state.isOpenConnect,
setIsOpenExtra: state.setIsOpenExtra,
isOpenExtra: state.isOpenExtra,
setIsChangelog: state.setIsChangelog,
isChangelog: state.isChangelog,
}))
)
const [importModal, setImportModal] = useState("enka");
const { isConnectPS, extraData } = useGlobalStore()
const { isConnectPS, extraData } = useGlobalStore(
useShallow((state) => ({
isConnectPS: state.isConnectPS,
extraData: state.extraData,
}))
)
useEffect(() => {
@@ -234,24 +274,149 @@ export default function Header() {
};
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>
<header className="sticky top-0 z-40 w-full bg-base-100 border-b border-base-300 shadow-xs">
<div className="navbar max-w-[1920px] mx-auto px-2 sm:px-4 py-1.5 min-h-14">
<div className="navbar-start gap-2">
{/* Mobile menu dropdown */}
<div className="dropdown">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm lg:hidden rounded-lg hover:bg-base-200 transition-all duration-200">
<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-2xl z-50 mt-3 w-56 p-2 shadow-2xl border border-base-content/15"
>
<li>
<details>
<summary className="px-3 py-2 hover:bg-base-200/70 rounded-lg transition-all duration-200 font-medium w-full">{transI18n("loadData")}</summary>
<ul className="p-2 space-y-1">
<li><a className="rounded-md" onClick={() => {
setImportModal("freesr")
setIsOpenImport(true)
}}>{transI18n("freeSr")}</a></li>
<li>
<>
<input
type="file"
accept="application/json"
id="database-data-upload"
className="hidden"
onChange={handleImportDatabase}
/>
<button
className="rounded-md w-full text-left"
onClick={() => document.getElementById('database-data-upload')?.click()}
>
{transI18n("database")}
</button>
</>
</li>
<li><a className="rounded-md" onClick={() => {
setImportModal("enka")
setIsOpenImport(true)
}}>{transI18n("enka")}</a></li>
</ul>
</details>
</li>
<li>
<details>
<summary className="px-3 py-2 hover:bg-base-200/70 rounded-lg transition-all duration-200 font-medium">{transI18n("exportData")}</summary>
<ul className="p-2 space-y-1">
<li><a className="rounded-md" onClick={() => downloadJson("freesr-data",
converterToFreeSRJson(
avatars,
battle_type,
moc_config,
pf_config,
as_config,
ce_config,
peak_config
)
)}>{transI18n("freeSr")}</a></li>
<li><a className="rounded-md" 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/70 rounded-lg transition-all duration-200 font-medium"
onClick={() => {
setIsOpenConnect(true)
}}
>
{transI18n("connectSetting")}
</button>
</li>
<li>
<button
onClick={() => {
setIsOpenMonster(true)
}}
className="px-3 py-2 hover:bg-base-200/70 rounded-lg transition-all duration-200 font-medium"
>
{transI18n("monsterSetting")}
</button>
</li>
{extraData && (
<li>
<button
onClick={() => {
setIsOpenExtra(true)
}}
className="px-3 py-2 hover:bg-base-200/70 rounded-lg transition-all duration-200 font-medium"
>
{transI18n("extraSetting")}
</button>
</li>
)}
</ul>
</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"
>
{/* Brand Logo */}
<a className="flex items-center gap-2.5 px-2 group cursor-pointer select-none">
<div className="relative">
<div className="absolute -inset-1 bg-linear-to-r from-emerald-500/20 to-orange-500/20 rounded-full blur-xs opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<Image
unoptimized
crossOrigin="anonymous"
src="/ff-srtool.png"
alt="Logo"
width={250}
height={250}
className="relative w-9 h-9 xl:w-10 xl:h-10 object-contain drop-shadow-sm transition-transform duration-300 group-hover:scale-105"
/>
</div>
<div className="flex flex-col justify-center items-start">
<h1 className="text-base xl:text-lg font-bold leading-tight tracking-tight flex items-center gap-1">
<span className="text-primary font-extrabold">Firefly</span>
<span className="bg-clip-text text-transparent bg-linear-to-r from-emerald-400 via-amber-400 to-rose-400">
SrTools
</span>
</h1>
<p className="text-[11px] text-base-content/50 font-medium tracking-wide uppercase">Firefly Shelter</p>
</div>
</a>
</div>
{/* Desktop navigation */}
<div className="navbar-center hidden lg:flex">
<ul className="menu menu-horizontal gap-1.5 p-0 text-sm font-medium">
<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={() => {
<summary className="px-3 py-1.5 hover:bg-base-200/70 rounded-lg transition-all duration-200">{transI18n("loadData")}</summary>
<ul className="p-2 bg-base-100 rounded-xl shadow-xl border border-base-content/15 space-y-1 w-44 z-50">
<li><a className="rounded-md" onClick={() => {
setImportModal("freesr")
setIsOpenImport(true)
}}>{transI18n("freeSr")}</a></li>
@@ -265,13 +430,14 @@ export default function Header() {
onChange={handleImportDatabase}
/>
<button
className="rounded-md w-full text-left"
onClick={() => document.getElementById('database-data-upload')?.click()}
>
{transI18n("database")}
</button>
</>
</li>
<li><a onClick={() => {
<li><a className="rounded-md" onClick={() => {
setImportModal("enka")
setIsOpenImport(true)
}}>{transI18n("enka")}</a></li>
@@ -280,9 +446,9 @@ export default function Header() {
</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",
<summary className="px-3 py-1.5 hover:bg-base-200/70 rounded-lg transition-all duration-200">{transI18n("exportData")}</summary>
<ul className="p-2 bg-base-100 rounded-xl shadow-xl border border-base-content/15 space-y-1 w-44 z-50">
<li><a className="rounded-md" onClick={() => downloadJson("freesr-data",
converterToFreeSRJson(
avatars,
battle_type,
@@ -293,7 +459,7 @@ export default function Header() {
peak_config
)
)}>{transI18n("freeSr")}</a></li>
<li><a onClick={() => downloadJson("database-data", {
<li><a className="rounded-md" onClick={() => downloadJson("database-data", {
avatars: avatars,
battle_type: battle_type,
moc_config: moc_config,
@@ -307,7 +473,7 @@ export default function Header() {
</li>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
className="px-3 py-1.5 hover:bg-base-200/70 rounded-lg transition-all duration-200"
onClick={() => {
setIsOpenConnect(true)
}}
@@ -320,19 +486,18 @@ export default function Header() {
onClick={() => {
setIsOpenMonster(true)
}}
className="disabled px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
className="px-3 py-1.5 hover:bg-base-200/70 rounded-lg transition-all duration-200"
>
{transI18n("monsterSetting")}
</button>
</li>
{extraData && (
<li>
<button
onClick={() => {
setIsOpenExtra(true)
}}
className="disabled px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
className="px-3 py-1.5 hover:bg-base-200/70 rounded-lg transition-all duration-200"
>
{transI18n("extraSetting")}
</button>
@@ -341,279 +506,154 @@ export default function Header() {
</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
unoptimized
crossOrigin="anonymous"
src="/ff-srtool.png"
alt="Logo"
width={250}
height={250}
className="w-10 h-10 xl:w-12 xl:h-12 object-contain"
/>
<div className="flex flex-col justify-center items-start">
<h1 className="text-lg xl:text-xl font-bold leading-tight">
<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">Firefly Shelter</p>
{/* Right side items */}
<div className="navbar-end gap-2 pr-1">
{/* PS Connection Status Badge */}
<div data-tip={isConnectPS ? "Connected to PS" : "Not Connected"} className="tooltip tooltip-bottom">
<div className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-base-200/60 border border-white/5 shadow-xs">
<span className="relative flex h-2 w-2">
{isConnectPS && (
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
)}
<span className={`relative inline-flex rounded-full h-2 w-2 ${isConnectPS ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]' : 'bg-rose-500'}`}></span>
</span>
<span className={`hidden xl:block text-xs font-semibold tracking-tight ${isConnectPS ? 'text-emerald-500' : 'text-base-content/50'}`}>
{isConnectPS ? transI18n("connected") : transI18n("unconnected")}
</span>
</div>
</div>
</a>
</div>
{/* View Logs Button */}
<button
data-tip="Changelog"
title="Changelog"
className="btn btn-ghost btn-sm btn-circle tooltip tooltip-bottom text-warning/90 hover:bg-warning/10 transition-colors"
onClick={() => {
setIsChangelog(true)
}}
>
<ScrollText className="w-4 h-4" />
</button>
{/* 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)
}}>{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)
}}>{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)
}}
{/* Language selector */}
<div className="dropdown dropdown-end tooltip tooltip-bottom" data-tip={transI18n("language") || "Language"}>
<div
tabIndex={0}
role="button"
className="flex items-center gap-1.5 border border-base-content/10 bg-base-200/40 rounded-lg text-xs px-2.5 py-1 hover:bg-base-200/80 transition-all duration-200 font-semibold uppercase tracking-wider"
>
{transI18n("connectSetting")}
</button>
</li>
<li>
<button
onClick={() => {
setIsOpenMonster(true)
}}
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)
}}
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 pr-1 ">
<div data-tip="Connection Status" className="tooltip tooltip-bottom">
<div className="flex items-center space-x-2 p-1.5 rounded-full shadow-md">
<div className={`hidden xl:block text-sm italic ${isConnectPS ? 'text-green-500' : 'text-red-500'}`}>
{isConnectPS ? transI18n("connected") : transI18n("unconnected")}
<span className="font-bold">{listCurrentLanguage[locale as keyof typeof listCurrentLanguage]?.flag || "EN"}</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-3.5 opacity-60">
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
</svg>
</div>
<div className={`w-3 h-3 rounded-full ${isConnectPS ? 'bg-green-500' : 'bg-red-500'}`}></div>
</div>
</div>
<div
data-tip="View Logs"
className="btn btn-ghost btn-sm btn-circle items-center justify-center w-fit tooltip tooltip-bottom"
onClick={() => {
setIsChangelog(true)
}}
>
<ScrollText className="w-5 h-5 text-warning" />
</div>
{/* Language selector - REFINED */}
<div className="dropdown dropdown-end">
{/* Nút bấm hiển thị */}
<div
tabIndex={0}
role="button"
className="flex items-center gap-1 border border-base-300 rounded text-sm px-2 py-0.5 hover:bg-base-200 transition-all duration-200 uppercase font-medium"
>
<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>
{listCurrentLanguage[locale as keyof typeof listCurrentLanguage]?.flag || "EN"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="size-4 opacity-50">
<path fillRule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
</svg>
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow bg-base-100 rounded-box w-52 mt-2"
>
{Object.entries(listCurrentLanguage).map(([key, value]) => (
<li key={key}>
<button
className={`flex justify-between ${locale === key ? "active" : ""}`}
onClick={() => {
changeLocale(key);
const elem = document.activeElement;
if (elem instanceof HTMLElement) {
elem.blur();
}
}}
>
<span className="font-bold">{value.flag}</span>
<span>{value.label}</span>
</button>
</li>
))}
</ul>
</div>
<div 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"
<ul
tabIndex={0}
className="dropdown-content z-50 menu p-1.5 shadow-2xl bg-base-100 rounded-xl w-48 mt-2 border border-base-content/15 space-y-0.5"
>
<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());
}}
>
{Object.entries(listCurrentLanguage).map(([key, value]) => (
<li key={key}>
<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"
className={`flex justify-between rounded-lg py-1.5 text-xs font-medium ${locale === key ? "active bg-primary/20 text-primary font-bold" : "hover:bg-base-200/70"}`}
onClick={() => {
changeLocale(key);
const elem = document.activeElement;
if (elem instanceof HTMLElement) {
elem.blur();
}
}}
>
<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>
<span className="font-bold">{value.flag}</span>
<span>{value.label}</span>
</button>
</li>
))}
</ul>
</div>
{/* Theme selector */}
<div className="dropdown dropdown-end tooltip tooltip-bottom" data-tip="Theme">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm rounded-lg hover:bg-base-200/70 transition-all duration-200 px-2" title="Theme">
<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>
</div>
<div
tabIndex={0}
className="dropdown-content bg-base-100 text-base-content rounded-xl overflow-hidden border border-base-content/15 shadow-2xl mt-2 z-50"
>
<ul className="menu w-36 p-1.5 space-y-0.5">
{themes.map((theme) => (
<li
key={theme.label}
onClick={() => {
if (changeTheme) changeTheme(theme.label.toLowerCase());
}}
>
<button
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-base-200/70 rounded-lg text-xs font-medium transition-all duration-200"
data-set-theme={theme.label.toLowerCase()}
>
<div
data-theme={theme.label.toLowerCase()}
className="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded p-1 shadow-xs border border-white/10"
>
<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>
<span>{theme.label}</span>
</button>
</li>
))}
</ul>
</div>
</div>
</div>
</div>
{/* Modals */}
{modalConfigs?.map(({ id, title, onClose, content }) => (
<dialog key={id} id={id} className="modal">
<div className="modal-box w-11/12 max-w-[90%] 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"
<div className={`modal-box w-11/12 max-w-[92vw] sm:max-w-[88vw] ${id === 'monster_modal' ? 'h-[85vh] max-h-[85vh] flex flex-col' : 'max-h-[90vh]'} bg-base-100 text-base-content border border-base-content/10 shadow-2xl rounded-2xl p-0 overflow-hidden`}>
{/* Modal Header */}
<div className="sticky top-0 z-20 flex items-center justify-between px-6 py-4 border-b border-base-content/10 bg-base-100 shrink-0">
<h3 className="font-bold text-xl tracking-tight bg-clip-text text-transparent bg-linear-to-r from-primary via-accent to-secondary">
{title}
</h3>
<button
className="btn btn-sm btn-circle btn-ghost text-base-content/60 hover:text-base-content hover:bg-base-200 transition-colors"
onClick={onClose}
>
✕
</motion.button>
</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>
{/* Modal Body */}
<div className="p-4 sm:p-6 overflow-y-auto flex-1">
{content}
</div>
{content}
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
))}
</div>
</header>
)
}
+178 -121
View File
@@ -11,12 +11,32 @@ import { useTranslations } from "next-intl";
import SelectCustomImage from "../select/customSelectImage";
import useCurrentDataStore from "@/stores/currentDataStore";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
export default function CopyImport() {
const { avatars, setAvatar } = useUserDataStore();
const { avatarSelected } = useCurrentDataStore()
const { mapAvatar, baseType, damageType } = useDetailDataStore()
const { locale } = useLocaleStore()
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatar: state.setAvatar,
}))
);
const { avatarSelected } = useCurrentDataStore(
useShallow((state) => ({
avatarSelected: state.avatarSelected,
}))
);
const { mapAvatar, baseType, damageType } = useDetailDataStore(
useShallow((state) => ({
mapAvatar: state.mapAvatar,
baseType: state.baseType,
damageType: state.damageType,
}))
);
const { locale } = useLocaleStore(
useShallow((state) => ({
locale: state.locale,
}))
);
const {
selectedProfiles,
avatarCopySelected,
@@ -28,7 +48,20 @@ export default function CopyImport() {
setListElement,
setListPath,
setListRank
} = useCopyProfileStore()
} = useCopyProfileStore(
useShallow((state) => ({
selectedProfiles: state.selectedProfiles,
avatarCopySelected: state.avatarCopySelected,
setSelectedProfiles: state.setSelectedProfiles,
setAvatarCopySelected: state.setAvatarCopySelected,
listElement: state.listElement,
listPath: state.listPath,
listRank: state.listRank,
setListElement: state.setListElement,
setListPath: state.setListPath,
setListRank: state.setListRank,
}))
);
const transI18n = useTranslations("DataPage")
const [message, setMessage] = useState({
@@ -129,15 +162,14 @@ export default function CopyImport() {
if (!selectedKeys.has(index) || (!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 cloned = structuredClone(profile);
cloned.profile_name = profile.profile_name + ` - Copy: ${avatarCopySelected?.ID}`;
return cloned as AvatarProfileStore;
}).filter((profile): profile is AvatarProfileStore => profile !== null);
const newAvatar = {
...targetAvatar,
profileList: targetAvatar.profileList.concat(newListProfile),
profileList: [...targetAvatar.profileList, ...newListProfile],
profileSelect: targetAvatar.profileList.length + newListProfile.length - 1,
}
setAvatar(newAvatar);
@@ -149,144 +181,169 @@ export default function CopyImport() {
};
return (
<div className="rounded-lg px-2 min-h-[60vh]">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-2">
<div className="rounded-2xl p-2 sm:p-4 min-h-[50vh] space-y-4">
<div className="max-w-7xl mx-auto space-y-4">
{/* Header & Filters Card */}
<div className="bg-base-100 rounded-2xl p-4 border border-base-300 shadow-sm space-y-4">
{/* Filters Section */}
<div className="space-y-3">
<div className="text-xs font-bold uppercase tracking-wider text-base-content/60">{transI18n("filter")}</div>
<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(baseType).filter(([key]) => key !== "").map(([key, value]) => (
<div
key={key}
onClick={() => {
setListPath({ ...listPath, [key]: !listPath[key] })
}}
className="w-12.5 h-12.5 hover:bg-gray-600 grid items-center justify-items-center rounded-md shadow-md cursor-pointer"
style={{
backgroundColor: listPath[key] ? "#374151" : "#6B7280"
}}>
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${value.Icon}`}
alt={key}
className="h-8 w-8 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(damageType).filter(([key]) => key !== "").map(([key, value]) => (
<div
key={key}
onClick={() => {
setListElement({ ...listElement, [key]: !listElement[key] })
}}
className="w-12.5 h-12.5 hover:bg-gray-600 grid items-center justify-items-center rounded-md shadow-md cursor-pointer"
style={{
backgroundColor: listElement[key] ? "#374151" : "#6B7280"
}}>
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${value.Icon}`}
alt={key}
className="h-7 w-7 2xl:h-10 2xl:w-10 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-12.5 h-12.5 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-8 w-8 text-center flex items-center justify-center">{key}*</div>
</div>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 sm:gap-4">
{/* Path Filters */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{Object.entries(baseType).filter(([key]) => key !== "").map(([key, value]) => {
const isActive = !!listPath[key];
return (
<button
key={key}
type="button"
onClick={() => setListPath({ ...listPath, [key]: !listPath[key] })}
className={`h-10 w-10 sm:h-12 sm:w-12 rounded-2xl transition-all duration-150 active-press border flex items-center justify-center cursor-pointer ${
isActive
? "bg-primary/20 border-primary ring-2 ring-primary/50 shadow-sm scale-105"
: "bg-base-200 hover:bg-base-300 border-base-300 opacity-80 hover:opacity-100"
}`}
title={key}
>
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${value.Icon}`}
alt={key}
className={`w-6 h-6 sm:w-7 sm:h-7 object-contain ${
isActive ? "theme-path-icon-active" : "theme-path-icon"
}`}
width={32}
height={32}
/>
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-1 gap-2">
<div className="h-8 w-px bg-base-300 hidden md:block" />
{listAvatar.length > 0 && (
<div>
<div>{transI18n("characterName")}</div>
<SelectCustomImage
customSet={listAvatar.map((avatar) => ({
value: avatar.ID.toString(),
label: getNameChar(locale, transI18n, avatar),
imageUrl: `${process.env.CDN_URL}/${avatar.Image.AvatarIconPath}`
}))}
excludeSet={[]}
selectedCustomSet={avatarCopySelected?.ID.toString() || ""}
placeholder="Character Select"
setSelectedCustomSet={(value) => setAvatarCopySelected(mapAvatar[value] || null)}
/>
</div>
)}
{/* Element Filters */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{Object.entries(damageType).filter(([key]) => key !== "").map(([key, value]) => {
const isActive = !!listElement[key];
return (
<button
key={key}
type="button"
onClick={() => setListElement({ ...listElement, [key]: !listElement[key] })}
className={`h-10 w-10 sm:h-12 sm:w-12 rounded-2xl transition-all duration-150 active-press border flex items-center justify-center cursor-pointer ${
isActive
? "bg-primary/20 border-primary ring-2 ring-primary/50 shadow-sm scale-105"
: "bg-base-200 hover:bg-base-300 border-base-300 opacity-80 hover:opacity-100"
}`}
title={key}
>
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${value.Icon}`}
alt={key}
className="w-6 h-6 sm:w-7 sm:h-7 object-contain drop-shadow-xs"
width={32}
height={32}
/>
</button>
);
})}
</div>
<div className="h-8 w-px bg-base-300 hidden md:block" />
{/* Rank Filters */}
<div className="flex items-center gap-1.5 sm:gap-2">
{Object.entries(listRank).map(([key], index) => {
const isActive = !!listRank[key];
return (
<button
key={index}
type="button"
onClick={() => setListRank({ ...listRank, [key]: !listRank[key] })}
className={`h-10 px-3.5 sm:h-12 sm:px-4.5 rounded-2xl transition-all duration-150 active-press border flex items-center justify-center text-xs sm:text-sm font-mono font-bold cursor-pointer ${
isActive
? "bg-primary text-primary-content border-primary shadow-sm scale-105"
: "bg-base-200 hover:bg-base-300 text-base-content/70 border-base-300"
}`}
>
{key}★
</button>
);
})}
</div>
</div>
</div>
{/* Character Select Dropdown */}
{listAvatar.length > 0 && (
<div className="space-y-1.5 pt-2 border-t border-base-300/60">
<div className="text-xs font-bold text-base-content/70">{transI18n("characterName")}</div>
<SelectCustomImage
customSet={listAvatar.map((avatar) => ({
value: avatar.ID.toString(),
label: getNameChar(locale, transI18n, avatar),
imageUrl: `${process.env.CDN_URL}/${avatar.Image.AvatarIconPath}`
}))}
excludeSet={[]}
selectedCustomSet={avatarCopySelected?.ID.toString() || ""}
placeholder="Character Select"
setSelectedCustomSet={(value) => setAvatarCopySelected(mapAvatar[value] || null)}
/>
</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}
{/* Selection Action Bar */}
<div className="bg-base-200 border border-base-300 rounded-2xl p-3.5 flex flex-wrap items-center justify-between gap-3 shadow-xs">
<div className="flex items-center gap-2">
<span className="text-xs sm:text-sm font-bold text-base-content">
{transI18n("selectedProfiles")}:
</span>
<span className="badge badge-primary font-mono font-bold text-xs">
{selectedProfiles.length}
</span>
</div>
<div className="flex items-center gap-2">
<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"
className="btn btn-xs sm:btn-sm btn-ghost text-error hover:bg-error/10 border border-error/30 rounded-xl"
>
{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">
className="btn btn-xs sm:btn-sm btn-ghost text-primary hover:bg-primary/10 border border-primary/30 rounded-xl"
>
{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">
className="btn btn-xs sm:btn-sm btn-primary rounded-xl font-bold shadow-xs"
>
{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>
{message.type && message.text && (
<div className={`p-3 rounded-xl border text-xs sm:text-sm font-semibold flex items-center gap-2 ${
message.type === "error"
? "bg-error/10 text-error border-error/30"
: "bg-success/10 text-success border-success/30"
}`}>
<span>{message.type === "error" ? "⚠️" : "🎉"}</span>
<span>{message.text}</span>
</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) => {
+82 -45
View File
@@ -12,6 +12,7 @@ import { useTranslations } from "next-intl";
import { avatarEnkaDetailSchema, enkaResponseSchema } from "@/zod";
import { z } from "zod";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
type AvatarEnkaDetailInput = z.infer<typeof avatarEnkaDetailSchema>;
@@ -40,11 +41,33 @@ export default function EnkaImport() {
selectedCharacters,
setSelectedCharacters,
setEnkaData,
} = useEnkaStore();
} = useEnkaStore(
useShallow((state) => ({
uidInput: state.uidInput,
setUidInput: state.setUidInput,
enkaData: state.enkaData,
selectedCharacters: state.selectedCharacters,
setSelectedCharacters: state.setSelectedCharacters,
setEnkaData: state.setEnkaData,
}))
);
const transI18n = useTranslations("DataPage")
const { avatars, setAvatar } = useUserDataStore();
const { setIsOpenImport } = useModelStore()
const { mapAvatar } = useDetailDataStore()
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatar: state.setAvatar,
}))
);
const { setIsOpenImport } = useModelStore(
useShallow((state) => ({
setIsOpenImport: state.setIsOpenImport,
}))
)
const { mapAvatar } = useDetailDataStore(
useShallow((state) => ({
mapAvatar: state.mapAvatar,
}))
)
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
const validCharacters = enkaData?.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]) ?? []
@@ -115,81 +138,95 @@ export default function EnkaImport() {
const filterData = parsed.data.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId] && selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatarId))
filterData.forEach((character) => {
const newAvatar = { ...listAvatars[character.avatarId.toString()] }
if (Object.keys(newAvatar).length !== 0) {
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)
const currentAvatar = listAvatars[character.avatarId.toString()]
if (currentAvatar && Object.keys(currentAvatar).length !== 0) {
const newProfileList = [...(currentAvatar.profileList || [])]
const newProfile = converterOneEnkaDataToAvatarStore(character, newProfileList.length)
if (newProfile) {
newAvatar.profileList.push(newProfile)
newAvatar.profileSelect = newAvatar.profileList.length - 1
newProfileList.push(newProfile)
}
const newAvatar = {
...currentAvatar,
level: character.level ?? 0,
promotion: character.promotion ?? 0,
data: {
rank: character.rank ?? 0,
skills: character.skillTreeList.reduce((acc, skill) => {
acc[skill.pointId] = skill.level;
return acc;
}, {} as Record<string, number>),
},
profileList: newProfileList,
profileSelect: newProfile ? newProfileList.length - 1 : currentAvatar.profileSelect,
}
setAvatar(newAvatar)
}
})
setIsOpenImport(false)
toast.success(transI18n("importEnkaDataSuccess"))
};
return (
<div className="bg-slate-900 p-6">
<div className="max-w-7xl mx-auto">
<div className="bg-base-100 p-4 sm:p-6 rounded-2xl">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-white text-2xl font-bold mb-6">HSR UID</h1>
<div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">HSR UID</h1>
<div className="flex gap-4 items-center mb-6">
<div className="flex flex-wrap gap-3 items-center">
<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"
className="input input-bordered w-full max-w-xs rounded-xl bg-base-200 border-base-300 text-sm focus:border-primary"
placeholder="Enter UID"
/>
<button onClick={handlerFetchData} className="btn btn-success rounded-lg transition-colors">
<button onClick={handlerFetchData} className="btn btn-sm btn-success rounded-xl font-bold">
{transI18n("getData")}
</button>
{selectedCharacters.length > 0 && (
<button onClick={handleImport} className="btn btn-primary rounded-lg transition-colors">
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
{transI18n("import")}
</button>
)}
</div>
{Error && (
<div className="text-error text-base mt-2">😭 {Error}</div>
<div className="text-error text-sm font-semibold 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 className="bg-base-200 p-4 rounded-xl border border-base-300 text-base-content space-y-1">
<div className="text-sm font-semibold">Name: <span className="font-bold text-primary">{enkaData.detailInfo.nickname}</span></div>
<div className="text-sm font-semibold">UID: <span className="font-mono">{enkaData.detailInfo.uid}</span></div>
<div className="text-sm font-semibold">Level: <span className="font-bold">{enkaData.detailInfo.level}</span></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 className="flex flex-wrap items-center justify-between gap-3 bg-base-200 border border-base-300 p-3.5 rounded-2xl shadow-xs">
<div className="flex items-center gap-2">
<span className="text-xs sm:text-sm font-bold text-base-content">
{transI18n("selectedCharacters")}:
</span>
<span className="badge badge-primary font-mono font-bold text-xs">
{selectedCharacters.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={clearSelection}
className="btn btn-xs sm:btn-sm btn-ghost text-error hover:bg-error/10 border border-error/30 rounded-xl"
>
{transI18n("clearAll")}
</button>
<button
onClick={selectAll}
className="btn btn-xs sm:btn-sm btn-ghost text-primary hover:bg-primary/10 border border-primary/30 rounded-xl"
>
{transI18n("selectAll")}
</button>
</div>
</div>
</div>
{isLoading && (
+74 -40
View File
@@ -11,6 +11,7 @@ import { toast } from "react-toastify";
import { converterOneFreeSRDataToAvatarStore } from "@/helper";
import { useTranslations } from "next-intl";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
import { z } from "zod";
type FreeSRJsonInput = z.infer<typeof freeSrJsonSchema>;
@@ -38,12 +39,32 @@ const toCharacterInfoCard = (data: FreeSRJsonInput, character: FreeSRJsonInput["
}
export default function FreeSRImport() {
const { avatars, setAvatar } = useUserDataStore();
const { mapAvatar } = useDetailDataStore();
const { setIsOpenImport } = useModelStore()
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatar: state.setAvatar,
}))
);
const { mapAvatar } = useDetailDataStore(
useShallow((state) => ({
mapAvatar: state.mapAvatar,
}))
);
const { setIsOpenImport } = useModelStore(
useShallow((state) => ({
setIsOpenImport: state.setIsOpenImport,
}))
)
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
const { freeSRData, setFreeSRData, selectedCharacters, setSelectedCharacters } = useFreeSRStore()
const { freeSRData, setFreeSRData, selectedCharacters, setSelectedCharacters } = useFreeSRStore(
useShallow((state) => ({
freeSRData: state.freeSRData,
setFreeSRData: state.setFreeSRData,
selectedCharacters: state.selectedCharacters,
setSelectedCharacters: state.setSelectedCharacters,
}))
)
const transI18n = useTranslations("DataPage")
const parsedFreeSRData = freeSrJsonSchema.safeParse(freeSRData)
const validFreeSRData = parsedFreeSRData.success ? parsedFreeSRData.data : null
@@ -142,66 +163,79 @@ export default function FreeSRImport() {
const listAvatars = { ...avatars }
const filterData = Object.values(parsed.data.avatars).filter((character) => selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatar_id))
filterData.forEach((character) => {
const newAvatar = { ...listAvatars[character.avatar_id] }
if (Object.keys(newAvatar).length !== 0) {
newAvatar.level = character.level
newAvatar.promotion = character.promotion
newAvatar.data = {
rank: character.data.rank ?? 0,
skills: character.data.skills
}
const newProfile = converterOneFreeSRDataToAvatarStore(parsed.data, newAvatar.profileList.length, character.avatar_id)
const currentAvatar = listAvatars[character.avatar_id]
if (currentAvatar && Object.keys(currentAvatar).length !== 0) {
const newProfileList = [...(currentAvatar.profileList || [])]
const newProfile = converterOneFreeSRDataToAvatarStore(parsed.data, newProfileList.length, character.avatar_id)
if (newProfile) {
newAvatar.profileList.push(newProfile)
newAvatar.profileSelect = newAvatar.profileList.length - 1
newProfileList.push(newProfile)
}
const newAvatar = {
...currentAvatar,
level: character.level,
promotion: character.promotion,
data: {
rank: character.data.rank ?? 0,
skills: character.data.skills
},
profileList: newProfileList,
profileSelect: newProfile ? newProfileList.length - 1 : currentAvatar.profileSelect,
}
setAvatar(newAvatar)
}
})
setIsOpenImport(false)
toast.success(transI18n("importFreeSRDataSuccess"))
}
return (
<div className="bg-slate-900 p-6">
<div className="max-w-7xl mx-auto">
<div className="bg-base-100 p-4 sm:p-6 rounded-2xl">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-white text-2xl font-bold mb-6">{transI18n("freeSRImport")}</h1>
<div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">{transI18n("freeSRImport")}</h1>
<div className="flex gap-4 items-center mb-6">
<div className="flex flex-wrap gap-4 items-center">
<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>
<legend className="fieldset-legend text-xs font-semibold">{transI18n("pickAFile")}</legend>
<input type="file" accept=".json" className="file-input file-input-sm file-input-bordered rounded-xl" onChange={handlerReadFile} />
<label className="label text-[11px] text-base-content/60">{transI18n("onlySupportFreeSRJsonFile")}</label>
</fieldset>
{selectedCharacters.length > 0 && (
<button onClick={handleImport} className="btn btn-primary rounded-lg transition-colors">
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
{transI18n("import")}
</button>
)}
</div>
{Error && (
<div className="text-error text-base mt-2">😭 {Error}</div>
<div className="text-error text-sm font-semibold 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 className="flex flex-wrap items-center justify-between gap-3 bg-base-200 border border-base-300 p-3.5 rounded-2xl shadow-xs">
<div className="flex items-center gap-2">
<span className="text-xs sm:text-sm font-bold text-base-content">
{transI18n("selectedCharacters")}:
</span>
<span className="badge badge-primary font-mono font-bold text-xs">
{selectedCharacters.length}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={clearSelection}
className="btn btn-xs sm:btn-sm btn-ghost text-error hover:bg-error/10 border border-error/30 rounded-xl"
>
{transI18n("clearAll")}
</button>
<button
onClick={selectAll}
className="btn btn-xs sm:btn-sm btn-ghost text-primary hover:bg-primary/10 border border-primary/30 rounded-xl"
>
{transI18n("selectAll")}
</button>
</div>
</div>
</div>
{isLoading && (
+134 -67
View File
@@ -10,9 +10,13 @@ import { useTranslations } from "next-intl";
import useCurrentDataStore from "@/stores/currentDataStore";
import useDetailDataStore from "@/stores/detailDataStore";
import { calcRarity, getLocaleName } from "@/helper";
import { useShallow } from "zustand/react/shallow";
import { Search, X } from "lucide-react";
export default function LightconeBar() {
const { locale } = useLocaleStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const {
avatarSelected,
mapLightconePathActive,
@@ -21,10 +25,34 @@ export default function LightconeBar() {
setMapLightconeRankActive,
lightconeSearch,
setLightconeSearch
} = useCurrentDataStore()
const { setAvatar, avatars } = useUserDataStore()
const { setIsOpenLightcone } = useModelStore()
const { mapLightCone, baseType } = useDetailDataStore()
} = useCurrentDataStore(
useShallow((state) => ({
avatarSelected: state.avatarSelected,
mapLightconePathActive: state.mapLightconePathActive,
mapLightconeRankActive: state.mapLightconeRankActive,
setMapLightconePathActive: state.setMapLightconePathActive,
setMapLightconeRankActive: state.setMapLightconeRankActive,
lightconeSearch: state.lightconeSearch,
setLightconeSearch: state.setLightconeSearch,
}))
)
const { setAvatar, avatars } = useUserDataStore(
useShallow((state) => ({
setAvatar: state.setAvatar,
avatars: state.avatars,
}))
)
const { setIsOpenLightcone } = useModelStore(
useShallow((state) => ({
setIsOpenLightcone: state.setIsOpenLightcone,
}))
)
const { mapLightCone, baseType } = useDetailDataStore(
useShallow((state) => ({
mapLightCone: state.mapLightCone,
baseType: state.baseType,
}))
)
const transI18n = useTranslations("DataPage")
const listLightcone = useMemo(() => {
@@ -39,10 +67,13 @@ export default function LightconeBar() {
const allRankFalse = !Object.values(mapLightconeRankActive).some(v => v)
const allPathFalse = !Object.values(mapLightconePathActive).some(v => v)
list = list.filter(item =>
(allRankFalse || mapLightconeRankActive[item.Rarity]) &&
(allPathFalse || mapLightconePathActive[item.BaseType])
)
list = list.filter(item => {
const itemRank = calcRarity(item.Rarity).toString();
return (
(allRankFalse || !!mapLightconeRankActive[itemRank] || !!mapLightconeRankActive[item.Rarity]) &&
(allPathFalse || !!mapLightconePathActive[item.BaseType])
);
})
list.sort((a, b) => {
const r = calcRarity(b.Rarity) - calcRarity(a.Rarity)
@@ -53,89 +84,125 @@ export default function LightconeBar() {
return list
}, [mapLightCone, mapLightconePathActive, mapLightconeRankActive, lightconeSearch, 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>
<div className="space-y-4">
{/* Search & Filters */}
<div className="p-3 rounded-2xl bg-base-200 border border-base-300 space-y-2.5">
{/* Search Bar */}
<label className="input input-sm input-bordered flex items-center gap-2 bg-base-100 border-base-300 rounded-lg w-full h-8 px-2.5 shadow-xs">
<Search className="w-4 h-4 text-base-content/60 shrink-0" />
<input
value={lightconeSearch}
onChange={(e) => setLightconeSearch(e.target.value)}
type="text" placeholder="LightCone Name" className="input input-accent mt-1 w-full"
type="text"
placeholder={`${transI18n("lightcones")}...`}
className="grow text-xs bg-transparent border-none outline-none focus:outline-none"
/>
</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">
<div className="flex flex-wrap mb-1 mx-1 gap-2">
{Object.entries(baseType).filter(([key]) => key !== "").map(([key, value]) => (
<div
{lightconeSearch && (
<button
type="button"
onClick={() => setLightconeSearch("")}
className="text-base-content/40 hover:text-base-content"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</label>
{/* Filter Chips */}
<div className="flex flex-wrap items-center justify-between gap-2">
{/* Path Filter Chips */}
<div className="flex flex-wrap items-center gap-1">
{Object.entries(baseType).filter(([key]) => key !== "").map(([key, value]) => {
const isActive = !!mapLightconePathActive[key];
return (
<button
key={key}
type="button"
onClick={() => {
setMapLightconePathActive({ ...mapLightconePathActive, [key]: !mapLightconePathActive[key] })
}}
className="h-9.5 w-9.5 md:h-12.5 md:w-12.5 hover:bg-gray-600 grid place-items-center rounded-md shadow-lg cursor-pointer"
style={{
backgroundColor: mapLightconePathActive[key] ? "#374151" : "#6B7280"
}}
className={`h-7 w-7 rounded-lg transition-all duration-150 active-press border flex items-center justify-center ${
isActive
? "bg-primary/20 border-primary ring-1.5 ring-primary/50 shadow-xs scale-105"
: "bg-base-200 hover:bg-base-300 border-base-content/10 opacity-80 hover:opacity-100"
}`}
title={key}
>
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${value.Icon}`}
alt={key}
className="h-7 w-7 md:h-8 md:w-8 object-contain rounded-md"
width={200}
height={200}
className={`w-4.5 h-4.5 object-contain ${
isActive ? "theme-path-icon-active" : "theme-path-icon"
}`}
width={20}
height={20}
/>
</div>
))}
</div>
</button>
);
})}
</div>
<div className="flex flex-wrap mb-1 mx-1 gap-2">
{Object.keys(mapLightconeRankActive).map((key, index) => (
<div
key={index}
{/* Rarity Filter Chips */}
<div className="flex items-center gap-1.5">
{Object.keys(mapLightconeRankActive).map((key) => {
const isActive = !!mapLightconeRankActive[key];
return (
<button
key={key}
type="button"
onClick={() => {
setMapLightconeRankActive({ ...mapLightconeRankActive, [key]: !mapLightconeRankActive[key] })
}}
className="h-9.5 w-9.5 md:h-12.5 md:w-12.5 hover:bg-gray-600 grid place-items-center rounded-md shadow-lg cursor-pointer"
style={{
backgroundColor: mapLightconeRankActive[key] ? "#374151" : "#6B7280"
}}
className={`px-3 py-1 rounded-xl text-xs font-mono font-bold transition-all duration-200 active-press border ${
isActive
? "bg-primary text-primary-content border-primary shadow-xs scale-105"
: "bg-base-100 border-base-300 text-base-content/70 hover:text-base-content hover:bg-base-200"
}`}
>
<div className="font-bold text-white h-8 w-8 text-center flex items-center justify-center">
{key}*
</div>
</div>
))}
</div>
{key}★
</button>
);
})}
</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?.toString()]
avatar.profileList[avatar.profileSelect].lightcone = {
level: 80,
item_id: item.ID,
rank: 1,
promotion: 6
{/* Results Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-3 auto-rows-fr max-h-[58vh] overflow-y-auto pr-1">
{listLightcone.map((item) => (
<div
key={item.ID}
className="h-full flex flex-col"
onClick={() => {
if (avatarSelected) {
const avatar = avatars[avatarSelected.ID.toString()]
if (avatar) {
const profileIndex = avatar.profileSelect
const currentProfile = avatar.profileList?.[profileIndex]
if (currentProfile) {
const newProfileList = [...avatar.profileList]
newProfileList[profileIndex] = {
...currentProfile,
lightcone: {
level: 80,
item_id: item.ID,
rank: 1,
promotion: 6
}
}
setAvatar({
...avatar,
profileList: newProfileList
})
setIsOpenLightcone(false)
}
}
}
setAvatar({ ...avatar })
setIsOpenLightcone(false)
}
}}>
}}
>
<LightconeCard data={item} />
</div>
))}
+33 -10
View File
@@ -8,14 +8,30 @@ import Image from "next/image";
import { ASEvent, MonsterStore } from "@/types";
import { useTranslations } from "next-intl";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
export default function AsBar() {
const { locale } = useLocaleStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const {
as_config,
setAsConfig
} = useUserDataStore()
const { mapMonster, mapAS, damageType, hardLevelConfig, eliteConfig } = useDetailDataStore()
} = useUserDataStore(
useShallow((state) => ({
as_config: state.as_config,
setAsConfig: state.setAsConfig,
}))
)
const { mapMonster, mapAS, damageType, hardLevelConfig, eliteConfig } = useDetailDataStore(
useShallow((state) => ({
mapMonster: state.mapMonster,
mapAS: state.mapAS,
damageType: state.damageType,
hardLevelConfig: state.hardLevelConfig,
eliteConfig: state.eliteConfig,
}))
)
const transI18n = useTranslations("DataPage")
const challengeSelected = useMemo(() => {
@@ -108,6 +124,13 @@ export default function AsBar() {
if (targetEventList.length > 0) {
newBattleConfig.stage_id = targetEventList[0].ID
if (targetEventList[0].Invasion) {
newBattleConfig.blessings.push({
id: targetEventList[0].Invasion.MazeBuff.ID,
level: 1
})
}
for (const wave of targetEventList[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
@@ -135,10 +158,10 @@ export default function AsBar() {
if (!mapAS) return null
return (
<div className="py-8 relative">
<div className="pt-4 pb-2 relative space-y-4">
{/* Title Card */}
<div className="rounded-xl p-4 mb-2 border border-warning">
<div className="rounded-xl p-4 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={Object.values(mapAS).sort((a, b) => b.ID - a.ID).map((as) => ({
@@ -209,13 +232,13 @@ export default function AsBar() {
</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>
<div className="pt-3 border-t border-base-content/10 space-y-2">
<h2 className="text-xl font-bold text-info">{transI18n("turbulenceBuff")}</h2>
{challengeSelected ? (
challengeSelected.MazeBuff.map((buff, i) => (
<div
key={i}
className="text-base"
className="text-sm text-base-content/85 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, buff?.Desc) || "",
@@ -225,7 +248,7 @@ export default function AsBar() {
/>
))
) : (
<div className="text-base">{transI18n("noTurbulenceBuff")}</div>
<div className="text-sm text-base-content/50 italic">{transI18n("noTurbulenceBuff")}</div>
)}
</div>
</div>
@@ -246,7 +269,7 @@ export default function AsBar() {
const targetEvent = eventList[0];
return (
<div key={i} className="rounded-xl p-4 mt-2 border border-warning">
<div key={i} className="rounded-xl p-4 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{side.wave}</h2>
{targetEvent?.MonsterList?.map((wave, waveIndex) => (
+75 -69
View File
@@ -17,19 +17,37 @@ import { useTranslations } from "next-intl";
import useGlobalStore from "@/stores/globalStore";
import useDetailDataStore from "@/stores/detailDataStore";
import { MonsterDetail } from "@/types";
import { useShallow } from "zustand/react/shallow";
export default function CeBar() {
const [searchTerm, setSearchTerm] = useState("");
const [showSearchWaveId, setShowSearchWaveId] = useState<number | null>(null);
const { ce_config, setCeConfig } = useUserDataStore()
const { mapMonster, stage, damageType } = useDetailDataStore()
const { locale } = useLocaleStore()
const { ce_config, setCeConfig } = useUserDataStore(
useShallow((state) => ({
ce_config: state.ce_config,
setCeConfig: state.setCeConfig,
}))
)
const { mapMonster, stage, damageType } = useDetailDataStore(
useShallow((state) => ({
mapMonster: state.mapMonster,
stage: state.stage,
damageType: state.damageType,
}))
)
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const transI18n = useTranslations("DataPage")
const [showSearchStage, setShowSearchStage] = useState(false)
const [stageSearchTerm, setStageSearchTerm] = useState("")
const [stagePage, setStagePage] = useState(1)
const { extraData, setExtraData } = useGlobalStore()
const { extraData, setExtraData } = useGlobalStore(
useShallow((state) => ({
extraData: state.extraData,
setExtraData: state.setExtraData,
}))
)
const pageSize = 30
@@ -59,10 +77,6 @@ export default function CeBar() {
[monsterPage, filteredMonsters]
)
useEffect(() => {
setMonsterPage(1)
}, [searchTerm])
const stageList = useMemo(() => Object.values(stage).map((item) => ({
id: item.ID.toString(),
name: `${getLocaleName(locale, item.Name)} (${item.ID})`,
@@ -79,11 +93,6 @@ export default function CeBar() {
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
@@ -110,27 +119,26 @@ export default function CeBar() {
return (
<div className="z-4 py-8 h-full w-full" onClick={() => {
<div className="z-4 pt-4 pb-2 h-full w-full space-y-4" onClick={() => {
setShowSearchWaveId(null)
setShowSearchStage(false)
}}>
<div className="mb-4 w-full relative">
<div className="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"
className="btn btn-outline btn-warning w-full text-left flex items-center gap-2.5 rounded-xl shadow-xs"
onClick={(e) => {
e.stopPropagation()
setShowSearchStage(!showSearchStage)
}}
>
<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>
<Search className="w-4 h-4" />
<span className="text-left font-medium"> {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 onClick={(e) => e.stopPropagation()} className="absolute top-full mt-2 w-full z-50 border bg-base-200 border-base-content/20 rounded-xl p-4 shadow-xl">
<div className="flex items-center gap-2 mb-2">
<label className="input w-full">
@@ -147,46 +155,44 @@ export default function CeBar() {
</g>
</svg>
<input
type="search" className="grow"
placeholder={transI18n("searchStage")}
value={stageSearchTerm}
onChange={(e) => setStageSearchTerm(e.target.value)}
autoFocus
onChange={(e) => {
setStageSearchTerm(e.target.value)
setStagePage(1)
}}
type="search"
required
placeholder={transI18n("searchStage")}
/>
</label>
</div>
<div className="max-h-60 overflow-y-auto space-y-1">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 max-h-[48vh] overflow-y-auto pr-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>
))}
</>
paginatedStages.map((stage) => (
<button
key={stage.id}
className="btn btn-sm btn-outline justify-start"
onClick={(e) => {
e.stopPropagation()
if (ce_config.stage_id !== Number(stage.id)) {
setCeConfig({ ...ce_config, stage_id: Number(stage.id), cycle_count: 30 })
}
setShowSearchStage(false)
setStageSearchTerm("")
}}
>
<span className="truncate">{stage.name}</span>
</button>
))
) : (
<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">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mt-4">
<button
disabled={stagePage === 1}
className="btn btn-sm btn-outline btn-success mt-2"
className="btn btn-sm btn-outline btn-success"
onClick={(e) => {
e.stopPropagation()
setStagePage(stagePage - 1)
@@ -197,7 +203,7 @@ export default function CeBar() {
<button
disabled={!hasMorePages}
className="btn btn-sm btn-outline btn-success mt-2"
className="btn btn-sm btn-outline btn-success"
onClick={(e) => {
e.stopPropagation()
setStagePage(stagePage + 1)
@@ -211,12 +217,12 @@ export default function CeBar() {
)}
</div>
<div className="relative max-w-7xl mx-auto space-y-6">
<div className="relative w-full space-y-4">
{ce_config.monsters.map((wave, waveIndex) => (
<div key={waveIndex} className="card border border-slate-700/50 ">
<div className="card-body p-6">
<div key={waveIndex} className="rounded-xl border border-warning p-4 bg-base-100">
<div className="w-full">
<div className="flex items-center flex-wrap justify-between mb-4">
<h2 className="text-xl font-bold text-white">{transI18n("wave")} {waveIndex + 1}</h2>
<h2 className="text-xl font-bold text-info">{transI18n("wave")} {waveIndex + 1}</h2>
<div className="flex gap-2">
<button
disabled={waveIndex === 0}
@@ -430,7 +436,10 @@ export default function CeBar() {
type="search" className="grow"
placeholder={transI18n("searchMonster")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onChange={(e) => {
setSearchTerm(e.target.value)
setMonsterPage(1)
}}
autoFocus
/>
@@ -513,20 +522,17 @@ export default function CeBar() {
))}
{/* 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>
<button
onClick={() => {
const newCeConfig = structuredClone(ce_config)
newCeConfig.monsters.push([])
setCeConfig(newCeConfig)
}}
className="btn btn-outline btn-warning w-full flex items-center justify-center gap-2 rounded-xl shadow-xs font-semibold"
>
<Plus className="w-4 h-4" />
<span>{transI18n("addNewWave")}</span>
</button>
</div>
</div>
);
+49 -33
View File
@@ -6,9 +6,15 @@ import AsBar from "./as";
import { useTranslations } from "next-intl";
import CeBar from "./ce";
import PeakBar from "./peak";
import { useShallow } from "zustand/react/shallow";
export default function MonsterBar() {
const { battle_type, setBattleType } = useUserDataStore()
const { battle_type, setBattleType } = useUserDataStore(
useShallow((state) => ({
battle_type: state.battle_type,
setBattleType: state.setBattleType,
}))
)
const transI18n = useTranslations("DataPage")
const navItems = [
@@ -23,9 +29,9 @@ export default function MonsterBar() {
return (
<div className="min-h-screen">
<div className="w-full">
{/* Header Navigation */}
<nav className="border-b border-warning/30 relative pb-2">
<nav className="border-b border-base-content/10 relative pb-3">
<div className="flex items-center justify-center">
{/* Mobile Select */}
<div className="block md:hidden w-full">
@@ -43,38 +49,48 @@ export default function MonsterBar() {
</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 '
<div className="hidden md:grid grid-cols-3 lg:grid-cols-6 gap-2 w-full">
{navItems.map((item) => {
const isSelected = battle_type.toUpperCase() === item.value.toUpperCase()
return (
<button
key={item.name}
onClick={() => setBattleType(item.value.toUpperCase())}
className={`px-4 py-2.5 rounded-xl transition-all cursor-pointer duration-300 flex items-center justify-center space-x-2 text-sm font-semibold border ${
isSelected
? 'bg-success/20 border-warning shadow-lg text-base-content'
: 'bg-base-200/20 hover:bg-base-200/40 border-transparent text-base-content/80'
}`}
>
<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
unoptimized
crossOrigin="anonymous"
src={`/icon/${item.icon}.webp`}
alt={item.name}
width={24}
height={24}
/>
</span>
<span>{item.name}</span>
</button>
))}
<span
className={
isSelected
? ""
: "theme-path-icon"
}
style={
isSelected
? {
filter:
'brightness(0) saturate(100%) invert(63%) sepia(78%) saturate(643%) hue-rotate(1deg) brightness(93%) contrast(89%)',
}
: undefined
}
>
<Image
unoptimized
crossOrigin="anonymous"
src={`/icon/${item.icon}.webp`}
alt={item.name}
width={24}
height={24}
className="w-5 h-5 object-contain"
/>
</span>
<span>{item.name}</span>
</button>
)
})}
</div>
</div>
</nav>
+34 -10
View File
@@ -9,14 +9,30 @@ import Image from "next/image";
import { useTranslations } from "next-intl";
import { MoCEvent, MonsterStore } from "@/types";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
export default function MocBar() {
const { locale } = useLocaleStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const {
moc_config,
setMocConfig
} = useUserDataStore()
const { mapMonster, mapMoc, damageType, hardLevelConfig, eliteConfig } = useDetailDataStore()
} = useUserDataStore(
useShallow((state) => ({
moc_config: state.moc_config,
setMocConfig: state.setMocConfig,
}))
)
const { mapMonster, mapMoc, damageType, hardLevelConfig, eliteConfig } = useDetailDataStore(
useShallow((state) => ({
mapMonster: state.mapMonster,
mapMoc: state.mapMoc,
damageType: state.damageType,
hardLevelConfig: state.hardLevelConfig,
eliteConfig: state.eliteConfig,
}))
)
const transI18n = useTranslations("DataPage")
@@ -86,6 +102,14 @@ export default function MocBar() {
if (targetEventList.length > 0) {
newBattleConfig.stage_id = targetEventList[0].ID
if (targetEventList[0].Invasion) {
newBattleConfig.blessings.push({
id: targetEventList[0].Invasion.MazeBuff.ID,
level: 1
})
}
for (const wave of targetEventList[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
@@ -115,10 +139,10 @@ export default function MocBar() {
if (!mapMoc) return null
return (
<div className="py-8 relative">
<div className="pt-4 pb-2 relative space-y-4">
{/* Title Card */}
<div className="rounded-xl p-4 mb-2 border border-warning">
<div className="rounded-xl p-4 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={Object.values(mapMoc).sort((a, b) => b.ID - a.ID).map((moc) => ({
@@ -197,8 +221,8 @@ export default function MocBar() {
<div className="label-text font-bold text-success mb-2">StageId: {moc_config?.stage_id}</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">
<div className="pt-3 border-t border-base-content/10 space-y-2">
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={moc_config.use_turbulence_buff}
@@ -215,7 +239,7 @@ export default function MocBar() {
challengeSelected.MazeBuff.map((buff, i) => (
<div
key={i}
className="text-base"
className="text-sm text-base-content/85 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, buff?.Desc) || "",
@@ -225,7 +249,7 @@ export default function MocBar() {
/>
))
) : (
<div className="text-base">{transI18n("noTurbulenceBuff")}</div>
<div className="text-sm text-base-content/50 italic">{transI18n("noTurbulenceBuff")}</div>
)}
</div>
</div>
@@ -246,7 +270,7 @@ export default function MocBar() {
const targetEvent = eventList[0];
return (
<div key={i} className="rounded-xl p-4 mt-2 border border-warning">
<div key={i} className="rounded-xl p-4 border border-warning">
<h2 className="text-2xl font-bold mb-6 text-info">{side.wave}</h2>
{targetEvent?.MonsterList?.map((wave, waveIndex) => (
+377 -285
View File
@@ -1,308 +1,400 @@
"use client"
"use client";
import { useEffect, useMemo } from "react";
import SelectCustomText from "../select/customSelectText";
import { calcMonsterStats, getLocaleName, replaceByParam } from "@/helper";
import useLocaleStore from "@/stores/localeStore";
import useUserDataStore from "@/stores/userDataStore";;
import useUserDataStore from "@/stores/userDataStore";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { MonsterStore } from "@/types";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
export default function PeakBar() {
const { locale } = useLocaleStore()
const {
peak_config,
setPeakConfig
} = useUserDataStore()
const { mapMonster, mapPeak, damageType, eliteConfig, hardLevelConfig } = useDetailDataStore()
const transI18n = useTranslations("DataPage")
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale })),
);
const { peak_config, setPeakConfig } = useUserDataStore(
useShallow((state) => ({
peak_config: state.peak_config,
setPeakConfig: state.setPeakConfig,
})),
);
const { mapMonster, mapPeak, damageType, eliteConfig, hardLevelConfig } =
useDetailDataStore(
useShallow((state) => ({
mapMonster: state.mapMonster,
mapPeak: state.mapPeak,
damageType: state.damageType,
eliteConfig: state.eliteConfig,
hardLevelConfig: state.hardLevelConfig,
})),
);
const transI18n = useTranslations("DataPage");
const listFloor = useMemo(() => {
const peak = mapPeak?.[peak_config?.event_id?.toString()]
if (!peak) return []
const listFloor = useMemo(() => {
const peak = mapPeak?.[peak_config?.event_id?.toString()];
if (!peak) return [];
return [...peak.PreLevel, peak.BossLevel].filter(it => it != null)
}, [peak_config, mapPeak])
const eventSelected = useMemo(() => {
return mapPeak?.[peak_config?.event_id?.toString()]
}, [peak_config, mapPeak])
return [...peak.PreLevel, peak.BossLevel].filter((it) => it != null);
}, [peak_config, mapPeak]);
const eventSelected = useMemo(() => {
return mapPeak?.[peak_config?.event_id?.toString()];
}, [peak_config, mapPeak]);
const bossConfig = useMemo(() => {
return mapPeak?.[peak_config?.event_id?.toString()]?.BossConfig;
}, [peak_config, mapPeak])
const bossConfig = useMemo(() => {
return mapPeak?.[peak_config?.event_id?.toString()]?.BossConfig;
}, [peak_config, mapPeak]);
const challengeSelected = useMemo(() => {
const challenge = structuredClone(listFloor?.find((peak) => peak?.ID === peak_config.challenge_id))
if (
challenge
&& challenge.ID === mapPeak?.[peak_config?.event_id?.toString()]?.BossLevel?.ID
&& bossConfig
&& peak_config?.boss_mode === "Hard"
) {
return { challenge: bossConfig, isBoss: true }
const challengeSelected = useMemo(() => {
const challenge = structuredClone(
listFloor?.find((peak) => peak?.ID === peak_config.challenge_id),
);
if (
challenge &&
challenge.ID ===
mapPeak?.[peak_config?.event_id?.toString()]?.BossLevel?.ID &&
bossConfig &&
peak_config?.boss_mode === "Hard"
) {
return { challenge: bossConfig, isBoss: true };
}
return {
challenge: challenge,
isBoss:
challenge?.ID ===
mapPeak?.[peak_config?.event_id?.toString()]?.BossLevel?.ID,
};
}, [peak_config, listFloor, mapPeak, bossConfig]);
useEffect(() => {
if (!challengeSelected.challenge) 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?.challenge?.MazeBuff) {
newBattleConfig.blessings.push({
id: value.ID,
level: 1,
});
}
if (peak_config.buff_id !== 0 && challengeSelected.isBoss) {
newBattleConfig.blessings.push({
id: peak_config.buff_id,
level: 1,
});
}
newBattleConfig.monsters = [];
newBattleConfig.stage_id = challengeSelected.challenge.EventList[0].ID;
if (challengeSelected.challenge.EventList[0].Invasion) {
console.log("Adding Invasion Buff", challengeSelected.challenge.EventList[0].Invasion.MazeBuff.ID);
newBattleConfig.blessings.push({
id: challengeSelected.challenge.EventList[0].Invasion.MazeBuff.ID,
level: 1,
});
}
for (const wave of challengeSelected.challenge.EventList[0].MonsterList) {
if (!wave) continue;
const newWave: MonsterStore[] = [];
for (const value of Object.values(wave)) {
if (!value) continue;
newWave.push({
monster_id: value,
level: challengeSelected.challenge.EventList[0].Level,
amount: 1,
});
}
return {
challenge: challenge,
isBoss: challenge?.ID === mapPeak?.[peak_config?.event_id?.toString()]?.BossLevel?.ID,
}
}, [peak_config, listFloor, mapPeak, bossConfig])
newBattleConfig.monsters.push(newWave);
}
useEffect(() => {
if (!challengeSelected.challenge) 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?.challenge?.MazeBuff) {
newBattleConfig.blessings.push({
id: value.ID,
level: 1
})
setPeakConfig(newBattleConfig);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
peak_config.event_id,
peak_config.challenge_id,
peak_config.buff_id,
peak_config.boss_mode,
mapPeak,
]);
if (!mapPeak) return null;
return (
<div className="pt-4 pb-2 relative space-y-4">
{/* Title Card */}
<div className="rounded-xl p-4 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={Object.values(mapPeak)
.sort((a, b) => b.ID - a.ID)
.map((peak) => ({
id: peak.ID.toString(),
name: `${getLocaleName(locale, peak.Name)} (${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,
})
}
if (peak_config.buff_id !== 0 && challengeSelected.isBoss) {
newBattleConfig.blessings.push({
id: peak_config.buff_id,
level: 1
})
}
newBattleConfig.monsters = []
newBattleConfig.stage_id = challengeSelected.challenge.EventList[0].ID
for (const wave of challengeSelected.challenge.EventList[0].MonsterList) {
if (!wave) continue
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
if (!value) continue
newWave.push({
monster_id: value,
level: challengeSelected.challenge.EventList[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,
peak_config.boss_mode,
mapPeak,
])
if (!mapPeak) 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={Object.values(mapPeak).sort((a, b) => b.ID - a.ID).map((peak) => ({
id: peak.ID.toString(),
name: `${getLocaleName(locale, peak.Name)} (${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
/>
</div>
{/* Settings */}
<div
className={`grid grid-cols-1
${eventSelected && eventSelected.BossLevel?.ID === peak_config.challenge_id ? "md:grid-cols-2" : ""}
gap-4 mb-2 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}>
{getLocaleName(locale, 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 })
}
>
<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}>{getLocaleName(locale, 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>
<div className="label-text font-bold text-success mb-2">StageId: {peak_config?.stage_id}</div>
{
eventSelected
&& eventSelected.BossLevel?.ID === peak_config.challenge_id
&& bossConfig
&& (
<div className="mb-4 w-full">
<SelectCustomText
customSet={
bossConfig.BuffList.map((buff) => ({
id: buff.ID.toString(),
name: getLocaleName(locale, buff?.Name || ""),
description: replaceByParam(getLocaleName(locale, 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?.challenge && challengeSelected?.challenge?.MazeBuff?.length > 0 ? (
challengeSelected.challenge.MazeBuff.map((subOption, index) => (
<div key={index}>
<label className="label">
<span className="label-text font-bold text-success">
{index + 1}. {getLocaleName(locale, subOption.Name)}
</span>
</label>
<div
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, 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-2 text-info">{getLocaleName(locale, challengeSelected?.challenge?.Name)}</h2>
{challengeSelected?.challenge && Object.values(challengeSelected?.challenge?.EventList?.[0]?.Infinite || []).map((waveValue, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold">{transI18n("wave")} {waveIndex + 1}</h3>
<div className="flex flex-wrap gap-2 mt-2">
{Array.from(new Set(waveValue.MonsterList)).map((monsterId, enemyIndex) => {
const monsterStats = calcMonsterStats(
mapMonster?.[monsterId.toString()],
waveValue.EliteGroup,
challengeSelected?.challenge?.EventList?.[0]?.HardLevelGroup || 0,
challengeSelected?.challenge?.EventList?.[0]?.Level || 0,
hardLevelConfig,
eliteConfig
);
return (
<div
key={enemyIndex}
className="group relative flex flex-col w-40 bg-base-100 rounded-2xl border border-base-300 shadow-md"
>
<div className="badge badge-warning badge-sm font-bold absolute top-2 right-2 z-10 shadow-sm">
Lv. {challengeSelected?.challenge?.EventList?.[0]?.Level}
</div>
<div className="relative w-full h-20 bg-base-200 flex items-center justify-center p-4 rounded-t-2xl">
{mapMonster?.[monsterId.toString()]?.Image?.IconPath && (
<div className="relative w-16 h-16 rounded-full border-2 border-base-300 shadow-md overflow-hidden group-hover:scale-110 transition-transform duration-300 bg-base-100">
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${mapMonster?.[monsterId.toString()]?.Image?.IconPath}`}
alt="Enemy Icon"
width={150}
height={150}
className="w-full h-full object-cover"
/>
</div>
)}
</div>
<div className="flex flex-col px-1 pb-2 pt-2">
<div className="flex flex-col space-y-1.5">
<div className="flex justify-between items-center bg-base-200 px-2.5 py-1.5 rounded-lg">
<span className="text-xs font-semibold text-error">HP</span>
<span className="text-sm font-bold text-base-content">{monsterStats.hp.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
</div>
<div className="flex justify-between items-center bg-base-200 px-2.5 py-1.5 rounded-lg">
<span className="text-xs font-semibold text-info">Speed</span>
<span className="text-sm font-bold text-base-content">{monsterStats.spd.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
</div>
<div className="flex justify-between items-center bg-base-200 px-2.5 py-1.5 rounded-lg">
<span className="text-xs font-semibold text-base-content/70">Toughness</span>
<span className="text-sm font-bold text-base-content">{monsterStats.stance.toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
</div>
</div>
<div className="mt-2 pt-2 border-t border-base-300 flex flex-col items-center">
<span className="text-[10px] text-base-content/60 font-bold uppercase tracking-widest mb-1.5">
Weakness
</span>
<div className="flex items-center justify-center gap-1.5 flex-wrap">
{mapMonster?.[monsterId.toString()]?.StanceWeakList?.map((icon, iconIndex) => (
<Image
key={iconIndex}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType[icon]?.Icon}`}
alt={icon}
width={40}
height={40}
className="h-6 w-6 object-contain rounded-full bg-base-300 border border-base-content/10 p-0.5 shadow-sm hover:scale-110 transition-transform"
/>
))}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
</div>
<option value={0} disabled={true}>
{transI18n("selectSide")}
</option>
<option value="Normal">{transI18n("normalMode")}</option>
<option value="Hard">{transI18n("hardMode")}</option>
</select>
</div>
)}
</div>
)
<div className="label-text font-bold text-success mb-2">
StageId: {peak_config?.stage_id}
</div>
{eventSelected &&
eventSelected.BossLevel?.ID === peak_config.challenge_id &&
bossConfig && (
<div className="mb-4 w-full">
<SelectCustomText
customSet={bossConfig.BuffList.map((buff) => ({
id: buff.ID.toString(),
name: getLocaleName(locale, buff?.Name || ""),
description: replaceByParam(
getLocaleName(locale, 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="pt-3 border-t border-base-content/10 space-y-2">
<h2 className="text-xl font-bold text-info">
{transI18n("turbulenceBuff")}
</h2>
{challengeSelected?.challenge &&
challengeSelected?.challenge?.MazeBuff?.length > 0 ? (
challengeSelected.challenge.MazeBuff.map((subOption, index) => (
<div key={index} className="space-y-1">
<span className="font-bold text-success text-sm">
{index + 1}. {getLocaleName(locale, subOption.Name)}
</span>
<div
className="text-sm text-base-content/85 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, subOption.Desc),
subOption.Param || [],
),
}}
/>
</div>
))
) : (
<div className="text-sm text-base-content/50 italic">
{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 border border-warning">
<h2 className="text-2xl font-bold mb-2 text-info">
{getLocaleName(locale, challengeSelected?.challenge?.Name)}
</h2>
{challengeSelected?.challenge &&
Object.values(
challengeSelected?.challenge?.EventList?.[0]?.Infinite || [],
).map((waveValue, waveIndex) => (
<div key={waveIndex} className="mb-6">
<h3 className="text-lg font-semibold">
{transI18n("wave")} {waveIndex + 1}
</h3>
<div className="flex flex-wrap gap-2 mt-2">
{Array.from(new Set(waveValue.MonsterList)).map(
(monsterId, enemyIndex) => {
const monsterStats = calcMonsterStats(
mapMonster?.[monsterId.toString()],
waveValue.EliteGroup,
challengeSelected?.challenge?.EventList?.[0]
?.HardLevelGroup || 0,
challengeSelected?.challenge?.EventList?.[0]?.Level ||
0,
hardLevelConfig,
eliteConfig,
);
return (
<div
key={enemyIndex}
className="group relative flex flex-col w-40 bg-base-100 rounded-2xl border border-base-300 shadow-md"
>
<div className="badge badge-warning badge-sm font-bold absolute top-2 right-2 z-10 shadow-sm">
Lv.{" "}
{
challengeSelected?.challenge?.EventList?.[0]
?.Level
}
</div>
<div className="relative w-full h-20 bg-base-200 flex items-center justify-center p-4 rounded-t-2xl">
{mapMonster?.[monsterId.toString()]?.Image
?.IconPath && (
<div className="relative w-16 h-16 rounded-full border-2 border-base-300 shadow-md overflow-hidden group-hover:scale-110 transition-transform duration-300 bg-base-100">
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${mapMonster?.[monsterId.toString()]?.Image?.IconPath}`}
alt="Enemy Icon"
width={150}
height={150}
className="w-full h-full object-cover"
/>
</div>
)}
</div>
<div className="flex flex-col px-1 pb-2 pt-2">
<div className="flex flex-col space-y-1.5">
<div className="flex justify-between items-center bg-base-200 px-2.5 py-1.5 rounded-lg">
<span className="text-xs font-semibold text-error">
HP
</span>
<span className="text-sm font-bold text-base-content">
{monsterStats.hp.toLocaleString(undefined, {
maximumFractionDigits: 0,
})}
</span>
</div>
<div className="flex justify-between items-center bg-base-200 px-2.5 py-1.5 rounded-lg">
<span className="text-xs font-semibold text-info">
Speed
</span>
<span className="text-sm font-bold text-base-content">
{monsterStats.spd.toLocaleString(
undefined,
{ maximumFractionDigits: 0 },
)}
</span>
</div>
<div className="flex justify-between items-center bg-base-200 px-2.5 py-1.5 rounded-lg">
<span className="text-xs font-semibold text-base-content/70">
Toughness
</span>
<span className="text-sm font-bold text-base-content">
{monsterStats.stance.toLocaleString(
undefined,
{ maximumFractionDigits: 0 },
)}
</span>
</div>
</div>
<div className="mt-2 pt-2 border-t border-base-300 flex flex-col items-center">
<span className="text-[10px] text-base-content/60 font-bold uppercase tracking-widest mb-1.5">
Weakness
</span>
<div className="flex items-center justify-center gap-1.5 flex-wrap">
{mapMonster?.[
monsterId.toString()
]?.StanceWeakList?.map((icon, iconIndex) => (
<Image
key={iconIndex}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType[icon]?.Icon}`}
alt={icon}
width={40}
height={40}
className="h-6 w-6 object-contain rounded-full bg-base-300 border border-base-content/10 p-0.5 shadow-sm hover:scale-110 transition-transform"
/>
))}
</div>
</div>
</div>
</div>
);
},
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
+37 -16
View File
@@ -8,14 +8,30 @@ import Image from "next/image";
import { MonsterStore, PFEvent } from "@/types";
import { useTranslations } from "next-intl";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
export default function PfBar() {
const { locale } = useLocaleStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const {
pf_config,
setPfConfig
} = useUserDataStore()
const { mapMonster, mapPF, damageType, hardLevelConfig, eliteConfig } = useDetailDataStore()
} = useUserDataStore(
useShallow((state) => ({
pf_config: state.pf_config,
setPfConfig: state.setPfConfig,
}))
)
const { mapMonster, mapPF, damageType, hardLevelConfig, eliteConfig } = useDetailDataStore(
useShallow((state) => ({
mapMonster: state.mapMonster,
mapPF: state.mapPF,
damageType: state.damageType,
hardLevelConfig: state.hardLevelConfig,
eliteConfig: state.eliteConfig,
}))
)
const transI18n = useTranslations("DataPage")
const challengeSelected = useMemo(() => {
@@ -89,6 +105,14 @@ export default function PfBar() {
if (targetEventList.length > 0) {
newBattleConfig.stage_id = targetEventList[0].ID
if (targetEventList[0].Invasion) {
newBattleConfig.blessings.push({
id: targetEventList[0].Invasion.MazeBuff.ID,
level: 1
})
}
for (const wave of targetEventList[0].MonsterList) {
const newWave: MonsterStore[] = []
for (const value of Object.values(wave)) {
@@ -116,10 +140,10 @@ export default function PfBar() {
if (!mapPF) return null
return (
<div className="py-8 relative">
<div className="pt-4 pb-2 relative space-y-4">
{/* Title Card */}
<div className="rounded-xl p-4 mb-2 border border-warning">
<div className="rounded-xl p-4 border border-warning">
<div className="mb-4 w-full">
<SelectCustomText
customSet={Object.values(mapPF).sort((a, b) => b.ID - a.ID).map((pf) => ({
@@ -190,16 +214,14 @@ export default function PfBar() {
</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>
<div className="pt-3 border-t border-base-content/10 space-y-2">
<h2 className="text-xl font-bold 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}. {getLocaleName(locale, subOption.Name)}</span>
</label>
<div key={index} className="space-y-1">
<span className="font-bold text-success text-sm">{index + 1}. {getLocaleName(locale, subOption.Name)}</span>
<div
className="text-base"
className="text-sm text-base-content/85 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, subOption.Desc) || "",
@@ -213,7 +235,7 @@ export default function PfBar() {
challengeSelected?.MazeBuff?.map((buff, i) => (
<div
key={i}
className="text-base"
className="text-sm text-base-content/85 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, buff?.Desc) || "",
@@ -223,13 +245,12 @@ export default function PfBar() {
/>
))
) : (
<div className="text-base">{transI18n("noTurbulenceBuff")}</div>
<div className="text-sm text-base-content/50 italic">{transI18n("noTurbulenceBuff")}</div>
)}
</div>
</div>
{/* Enemy Waves */}
{(pf_config?.challenge_id ?? 0) !== 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{floorSideList.map((side, i) => {
@@ -245,7 +266,7 @@ export default function PfBar() {
const targetEvent = eventList[0];
return (
<div key={i} className="rounded-xl p-4 mt-2 border border-warning">
<div key={i} className="rounded-xl p-4 border border-warning">
<h2 className="text-2xl font-bold mb-2 text-info">{side.wave}</h2>
{targetEvent && Object.values(targetEvent.Infinite || []).map((waveValue, waveIndex) => (
+125 -92
View File
@@ -9,14 +9,28 @@ import RelicShowcase from "../showcaseCard/relicShowcase";
import useLocaleStore from "@/stores/localeStore";
import useDetailDataStore from "@/stores/detailDataStore";
import useCurrentDataStore from "@/stores/currentDataStore";
import { useShallow } from "zustand/react/shallow";
export default function QuickView() {
const { avatars } = useUserDataStore()
const { avatars } = useUserDataStore(
useShallow((state) => ({ avatars: state.avatars }))
)
const transI18n = useTranslations("DataPage")
const { locale } = useLocaleStore()
const { avatarSelected, } = useCurrentDataStore()
const { mainAffix, subAffix, mapRelicSet, mapLightCone, mapAvatar } = useDetailDataStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const { avatarSelected } = useCurrentDataStore(
useShallow((state) => ({ avatarSelected: state.avatarSelected }))
)
const { mainAffix, subAffix, mapRelicSet, mapLightCone, mapAvatar } = useDetailDataStore(
useShallow((state) => ({
mainAffix: state.mainAffix,
subAffix: state.subAffix,
mapRelicSet: state.mapRelicSet,
mapLightCone: state.mapLightCone,
mapAvatar: state.mapAvatar,
}))
)
const avatarSkillTree = useMemo(() => {
if (!avatarSelected || !avatars[avatarSelected?.ID?.toString()]) return {}
@@ -33,18 +47,17 @@ export default function QuickView() {
const avatarProfile = useMemo(() => {
if (!avatarSelected || !avatarData) return
return avatarData?.profileList?.[avatarData?.profileSelect]
return avatarData?.profileList?.[avatarData?.profileSelect] ?? avatarData?.profileList?.[0]
}, [avatarSelected, avatarData])
const relicEffects = useMemo(() => {
const avatar = avatars[avatarSelected?.ID?.toString() || ""];
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 profile = avatar?.profileList?.[avatar?.profileSelect] ?? avatar?.profileList?.[0];
if (profile?.relics) {
for (const relic of Object.values(profile.relics)) {
if (relic && relic.relic_set_id) {
relicCount[relic.relic_set_id] = (relicCount[relic.relic_set_id] || 0) + 1;
}
}
}
@@ -108,7 +121,7 @@ export default function QuickView() {
mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.HPAdd,
avatarData.level
),
name: "HP",
name: transI18n("hp"),
icon: "spriteoutput/ui/avatar/icon/IconMaxHP.png",
unit: "",
round: 0
@@ -124,7 +137,7 @@ export default function QuickView() {
mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.AttackAdd,
avatarData.level
),
name: "ATK",
name: transI18n("atk"),
icon: "spriteoutput/ui/avatar/icon/IconAttack.png",
unit: "",
round: 0
@@ -140,7 +153,7 @@ export default function QuickView() {
mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.DefenceAdd,
avatarData.level
),
name: "DEF",
name: transI18n("def"),
icon: "spriteoutput/ui/avatar/icon/IconDefence.png",
unit: "",
round: 0
@@ -148,7 +161,7 @@ export default function QuickView() {
SPD: {
value: mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.SpeedBase || 0,
base: mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.SpeedBase || 0,
name: "SPD",
name: transI18n("speed"),
icon: "spriteoutput/ui/avatar/icon/IconSpeed.png",
unit: "",
round: 1
@@ -156,7 +169,7 @@ export default function QuickView() {
CRITRate: {
value: mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.CriticalChance || 0,
base: mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.CriticalChance || 0,
name: "CRIT Rate",
name: transI18n("critRate"),
icon: "spriteoutput/ui/avatar/icon/IconCriticalChance.png",
unit: "%",
round: 1
@@ -164,7 +177,7 @@ export default function QuickView() {
CRITDmg: {
value: mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.CriticalDamage || 0,
base: mapAvatar?.[avatarSelected?.ID?.toString()]?.Stats[charPromotion]?.CriticalDamage || 0,
name: "CRIT DMG",
name: transI18n("critDmg"),
icon: "spriteoutput/ui/avatar/icon/IconCriticalDamage.png",
unit: "%",
round: 1
@@ -172,7 +185,7 @@ export default function QuickView() {
BreakEffect: {
value: 0,
base: 0,
name: "Break Effect",
name: transI18n("breakEffect"),
icon: "spriteoutput/ui/avatar/icon/IconBreakUp.png",
unit: "%",
round: 1
@@ -180,7 +193,7 @@ export default function QuickView() {
EffectRES: {
value: 0,
base: 0,
name: "Effect RES",
name: transI18n("effectRes"),
icon: "spriteoutput/ui/avatar/icon/IconStatusResistance.png",
unit: "%",
round: 1
@@ -188,7 +201,7 @@ export default function QuickView() {
EnergyRate: {
value: 0,
base: 0,
name: "Energy Rate",
name: transI18n("energyRegenerationRate"),
icon: "spriteoutput/ui/avatar/icon/IconEnergyRecovery.png",
unit: "%",
round: 1
@@ -196,7 +209,7 @@ export default function QuickView() {
EffectHitRate: {
value: 0,
base: 0,
name: "Effect Hit Rate",
name: transI18n("effectHitRate"),
icon: "spriteoutput/ui/avatar/icon/IconStatusProbability.png",
unit: "%",
round: 1
@@ -204,7 +217,7 @@ export default function QuickView() {
HealBoost: {
value: 0,
base: 0,
name: "Healing Boost",
name: transI18n("outgoingHealingBoost"),
icon: "spriteoutput/ui/avatar/icon/IconHealRatio.png",
unit: "%",
round: 1
@@ -212,7 +225,7 @@ export default function QuickView() {
PhysicalAdd: {
value: 0,
base: 0,
name: "Physical Boost",
name: transI18n("physicalDmgBoost"),
icon: "spriteoutput/ui/avatar/icon/IconPhysicalAddedRatio.png",
unit: "%",
round: 1
@@ -220,7 +233,7 @@ export default function QuickView() {
FireAdd: {
value: 0,
base: 0,
name: "Fire Boost",
name: transI18n("fireDmgBoost"),
icon: "spriteoutput/ui/avatar/icon/IconFireAddedRatio.png",
unit: "%",
round: 1
@@ -228,7 +241,7 @@ export default function QuickView() {
IceAdd: {
value: 0,
base: 0,
name: "Ice Boost",
name: transI18n("iceDmgBoost"),
icon: "spriteoutput/ui/avatar/icon/IconIceAddedRatio.png",
unit: "%",
round: 1
@@ -236,7 +249,7 @@ export default function QuickView() {
ThunderAdd: {
value: 0,
base: 0,
name: "Thunder Boost",
name: transI18n("thunderDmgBoost"),
icon: "spriteoutput/ui/avatar/icon/IconThunderAddedRatio.png",
unit: "%",
round: 1
@@ -244,7 +257,7 @@ export default function QuickView() {
WindAdd: {
value: 0,
base: 0,
name: "Wind Boost",
name: transI18n("windDmgBoost"),
icon: "spriteoutput/ui/avatar/icon/IconWindAddedRatio.png",
unit: "%",
round: 1
@@ -252,7 +265,7 @@ export default function QuickView() {
QuantumAdd: {
value: 0,
base: 0,
name: "Quantum Boost",
name: transI18n("quantumDmgBoost"),
icon: "spriteoutput/ui/avatar/icon/IconQuantumAddedRatio.png",
unit: "%",
round: 1
@@ -260,7 +273,7 @@ export default function QuickView() {
ImaginaryAdd: {
value: 0,
base: 0,
name: "Imaginary Boost",
name: transI18n("imaginaryDmgBoost"),
icon: "spriteoutput/ui/avatar/icon/IconImaginaryAddedRatio.png",
unit: "%",
round: 1
@@ -268,7 +281,7 @@ export default function QuickView() {
ElationAdd: {
value: 0,
base: 0,
name: "Elation Boost",
name: transI18n("elation"),
icon: "spriteoutput/ui/avatar/icon/IconJoy.png",
unit: "%",
round: 1
@@ -284,7 +297,7 @@ export default function QuickView() {
)
statsData.HP.base += calcBaseStatRaw(
mapLightCone?.[avatarProfile?.lightcone?.item_id]?.Stats?.[lightconePromotion]?.BaseHP,
mapLightCone?.[avatarProfile?.lightcone?.item_id]?.Stats?.[lightconePromotion]?.BaseHPAdd,
mapLightCone?.[avatarProfile?.lightcone?.item_id]?.Stats[lightconePromotion]?.BaseHPAdd,
avatarProfile?.lightcone?.level
)
statsData.ATK.value += calcBaseStatRaw(
@@ -342,8 +355,6 @@ export default function QuickView() {
})
}
if (avatarProfile?.relics && mainAffix && subAffix) {
Object.entries(avatarProfile?.relics).forEach(([key, value]) => {
const mainAffixMap = mainAffix["5" + key]
@@ -378,7 +389,6 @@ export default function QuickView() {
})
}
return statsData
}, [
avatarSelected,
@@ -395,64 +405,89 @@ export default function QuickView() {
])
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={`${process.env.CDN_URL}/${stat?.icon}`}
unoptimized
crossOrigin="anonymous"
alt="Stat Icon"
width={40}
height={40}
className="h-10 w-10 p-1 mx-1 bg-black/20 rounded-full"
/>
<div className="font-bold">{stat.name}</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 pt-1">
{/* Stats Overview Column - Clean Minimal Grid, No Box-in-Box */}
<div className="lg:col-span-6 space-y-6">
<div className="space-y-3">
<div className="flex items-center justify-between pb-2 border-b border-base-content/10">
<div className="flex items-center gap-2">
<span className="w-1 h-3.5 bg-primary rounded-full" />
<h3 className="text-xs font-bold uppercase tracking-wider text-base-content/90">
{transI18n("character")}
</h3>
</div>
<span className="text-xs font-mono font-medium text-base-content/60">
{transI18n("level")} {avatarData?.level || 1}
</span>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-xs">
{Object.entries(characterStats || {})?.map(([key, stat], index) => {
if (!stat || (key.includes("Add") && stat.value === 0)) return null
return (
<div
key={index}
className="flex items-center justify-between py-1.5 border-b border-base-content/5"
>
<div className="flex items-center gap-2 min-w-0 pr-2">
<NextImage
src={`${process.env.CDN_URL}/${stat?.icon}`}
unoptimized
crossOrigin="anonymous"
alt={stat.name}
width={20}
height={20}
className="h-5 w-5 theme-stat-icon shrink-0 object-contain"
/>
<span className="font-medium text-base-content/80 truncate">{stat.name}</span>
</div>
<span className="font-mono font-semibold text-primary shrink-0 text-right">
{stat.value ? stat.unit === "%" ? (stat.value * 100).toFixed(stat.round) : stat.value.toFixed(stat.round) : 0}
<span className="text-[10px] font-normal opacity-80 ml-0.5">{stat.unit}</span>
</span>
</div>
<div className="ml-3 mr-3 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>
<div className="flex flex-col items-center gap-1 w-full my-2">
{relicEffects.map((setEffect, index) => {
const relicInfo = mapRelicSet[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(
getLocaleName(locale, 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>
{/* Set Effects - Flat Minimalist Row */}
{relicEffects.length > 0 && (
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2 pb-1 border-b border-base-content/10">
<span className="w-1 h-3.5 bg-amber-500 rounded-full" />
<h4 className="text-xs font-bold uppercase tracking-wider text-base-content/90">
{transI18n("relics")}
</h4>
</div>
<div className="space-y-1">
{relicEffects.map((setEffect, index) => {
const relicInfo = mapRelicSet[setEffect.key];
if (!relicInfo) return null;
return (
<div
key={index}
className="flex items-center justify-between text-xs py-1.5 border-b border-base-content/5"
>
<div
className="font-medium text-base-content/90 truncate max-w-[80%]"
dangerouslySetInnerHTML={{
__html: replaceByParam(getLocaleName(locale, relicInfo.Name), [])
}}
/>
<span className="font-mono font-semibold text-amber-600 dark:text-amber-400 text-xs shrink-0">
{setEffect.count} Pc
</span>
</div>
)
})}
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-2 justify-between py-3 text-lg">
{/* Relic Equipment Cards Column */}
<div className="lg:col-span-6 space-y-2">
{relicStats?.map((relic, index) => {
if (!relic || !avatarSelected) return null
return (
@@ -461,10 +496,8 @@ export default function QuickView() {
})}
{(!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 className="p-8 text-center text-base-content/40 text-xs italic">
{transI18n("noRelicEquipped")}
</div>
)}
</div>
+252 -212
View File
@@ -15,13 +15,37 @@ import useCurrentDataStore from '@/stores/currentDataStore';
import { RelicSetDetail, SubAffixData } from '@/types';
import useLocaleStore from '@/stores/localeStore';
import { mappingRelicSlot } from "@/constant/constant";
import { useShallow } from 'zustand/react/shallow';
export default function RelicMaker() {
const { avatars, setAvatars } = useUserDataStore()
const { avatarSelected } = useCurrentDataStore()
const { setIsOpenRelic } = useModelStore()
const { mainAffix, subAffix, mapRelicSet } = useDetailDataStore()
const { locale } = useLocaleStore()
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatar: state.setAvatar,
}))
)
const { avatarSelected } = useCurrentDataStore(
useShallow((state) => ({
avatarSelected: state.avatarSelected,
}))
)
const { setIsOpenRelic } = useModelStore(
useShallow((state) => ({
setIsOpenRelic: state.setIsOpenRelic,
}))
)
const { mainAffix, subAffix, mapRelicSet } = useDetailDataStore(
useShallow((state) => ({
mainAffix: state.mainAffix,
subAffix: state.subAffix,
mapRelicSet: state.mapRelicSet,
}))
)
const { locale } = useLocaleStore(
useShallow((state) => ({
locale: state.locale,
}))
)
const transI18n = useTranslations("DataPage")
const {
selectedRelicSlot,
@@ -37,7 +61,23 @@ export default function RelicMaker() {
resetHistory,
popHistory,
addHistory,
} = useRelicMakerStore()
} = useRelicMakerStore(
useShallow((state) => ({
selectedRelicSlot: state.selectedRelicSlot,
selectedRelicSet: state.selectedRelicSet,
selectedMainStat: state.selectedMainStat,
listSelectedSubStats: state.listSelectedSubStats,
selectedRelicLevel: state.selectedRelicLevel,
preSelectedSubStats: state.preSelectedSubStats,
setSelectedRelicSet: state.setSelectedRelicSet,
setSelectedMainStat: state.setSelectedMainStat,
setSelectedRelicLevel: state.setSelectedRelicLevel,
setListSelectedSubStats: state.setListSelectedSubStats,
resetHistory: state.resetHistory,
popHistory: state.popHistory,
addHistory: state.addHistory,
}))
)
const [error, setError] = useState<string>("");
const relicSets = useMemo(() => {
@@ -218,46 +258,57 @@ export default function RelicMaker() {
};
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
const profileIndex = avatar.profileSelect;
const currentProfile = avatar.profileList[profileIndex];
if (currentProfile) {
const newProfileList = [...avatar.profileList];
newProfileList[profileIndex] = {
...currentProfile,
relics: {
...currentProfile.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) => ({
sub_affix_id: Number(item.affixId),
count: item.rollCount,
step: item.stepCount
}))
}
}
})
};
setAvatar({
...avatar,
profileList: newProfileList
});
}
}
setAvatars({ ...avatars });
setIsOpenRelic(false);
toast.success(transI18n("relicSavedSuccessfully"));
}
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("relicMaker")}
</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
{/* Left Panel: Main Stat & Set Configuration */}
<div className="lg:col-span-5 space-y-4">
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/50 border border-base-content/10 shadow-xs space-y-4">
<div className="pb-3 border-b border-base-content/10">
<h2 className="text-base font-bold text-primary flex items-center gap-2">
<span className="w-1.5 h-4 bg-primary rounded-full"></span>
{transI18n("mainSettings")}
</h2>
</div>
{/* 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 className="space-y-3.5">
{/* Main Stat Picker */}
<div>
<label className="block text-lg font-medium mb-2">{transI18n("mainStat")}</label>
<label className="block text-xs font-bold uppercase tracking-wider text-base-content/75 mb-1.5">
{transI18n("mainStat")}
</label>
<SelectCustomImage
customSet={Object.entries(mainAffix["5" + selectedRelicSlot] || {}).map(([key, value]) => ({
value: key,
@@ -270,9 +321,12 @@ export default function RelicMaker() {
setSelectedCustomSet={setSelectedMainStat}
/>
</div>
{/* Relic Set Selection */}
{/* Relic Set Picker */}
<div>
<label className="block text-lg font-medium mb-2">{transI18n("set")}</label>
<label className="block text-xs font-bold uppercase tracking-wider text-base-content/75 mb-1.5">
{transI18n("set")}
</label>
<SelectCustomImage
customSet={Object.entries(relicSets).map(([key, value]) => ({
value: key,
@@ -285,212 +339,198 @@ export default function RelicMaker() {
setSelectedCustomSet={setSelectedRelicSet}
/>
</div>
</div>
{/* Set Bonus Display */}
<div className="mb-6 py-4 bg-base-100 rounded-lg">
{selectedRelicSet !== "" ? Object.entries(mapRelicSet[selectedRelicSet].Skills).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(
getLocaleName(locale, value.Desc),
value.Param || []
)
}}
/> </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>
{/* Set Bonus Display */}
<div className="py-2 px-1 text-xs border-t border-b border-base-content/10">
{selectedRelicSet !== "" && mapRelicSet[selectedRelicSet] ? (
Object.entries(mapRelicSet[selectedRelicSet].Skills).map(([key, value]) => (
<div key={key} className="mb-2 last:mb-0 space-y-0.5">
<span className="font-bold text-amber-500">{key}-Piece:</span>
<div
className="text-base-content/80 leading-relaxed pl-1"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, value.Desc),
value.Param || []
)
}}
/>
</div>
))
) : (
<p className="text-base-content/40 italic py-1">{transI18n("pleaseSelectASet")}</p>
)}
</div>
{/* Effect Bonus & Level */}
<div className="space-y-3 pt-1">
<div className="flex items-center justify-between text-xs font-semibold">
<span className="text-base-content/75">{transI18n("effectBonus")}:</span>
<span className="font-mono text-primary font-bold text-sm">+{effectBonus}</span>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs font-semibold">
<span className="text-base-content/75">{transI18n("level")}</span>
<span className="font-mono text-primary font-bold">+{selectedRelicLevel}</span>
</div>
<input
type="range"
min="0"
max="15"
value={selectedRelicLevel}
onChange={(e) => setSelectedRelicLevel(parseInt(e.target.value))}
className="range range-primary range-xs w-full"
/>
</div>
</div>
{/* Error Message */}
<AnimatePresence>
{error && (
<motion.p
key="error"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="text-xs text-error font-bold text-center bg-error/10 p-2 rounded-lg border border-error/20"
>
{error}
</motion.p>
)}
</AnimatePresence>
{/* Save Button */}
<button
onClick={handlerSaveRelic}
className="btn btn-primary w-full rounded-xl font-bold shadow-xs mt-2"
>
{transI18n("save")}
</button>
</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>
{/* Right Panel: Sub Stats Configurator */}
<div className="lg:col-span-7 space-y-3">
{/* Header with Total Rolls & Randomizers */}
<div className="p-3 rounded-2xl bg-base-200/50 border border-base-content/10 flex flex-wrap items-center justify-between gap-2 shadow-xs">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-base-content/75">{transI18n("totalRoll")}:</span>
<span className="text-sm font-mono font-bold px-2 py-0.5 rounded-full bg-primary/20 text-primary border border-primary/30">
{listSelectedSubStats.reduce((a, b) => a + b.rollCount, 0)}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2">
<button
className="btn btn-outline btn-success sm:btn-sm"
className="btn btn-xs btn-outline btn-primary rounded-lg font-semibold"
onClick={randomizeStats}
>
{transI18n("randomizeStats")}
</button>
<button
className="btn btn-outline btn-success sm:btn-sm"
className="btn btn-xs btn-outline btn-accent rounded-lg font-semibold"
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: `${process.env.CDN_URL}/${mappingStats[value.Property].icon}`
}))}
excludeSet={Object.entries(exSubAffixOptions).map(([key, value]) => ({
value: key,
label: mappingStats[value.Property].name + " " + mappingStats[value.Property].unit,
imageUrl: `${process.env.CDN_URL}/${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>
{/* 4 Sub-Stat Slots */}
<div className="space-y-2.5">
{listSelectedSubStats.map((v, index) => (
<div key={index} className="p-3.5 rounded-2xl bg-base-200/40 border border-base-content/10 space-y-2.5 shadow-xs">
<div className="flex items-center justify-between gap-2">
<div className="flex-1">
<SelectCustomImage
customSet={Object.entries(subAffixOptions).map(([key, value]) => ({
value: key,
label: mappingStats[value.Property].name + " " + mappingStats[value.Property].unit,
imageUrl: `${process.env.CDN_URL}/${mappingStats[value.Property].icon}`
}))}
excludeSet={Object.entries(exSubAffixOptions).map(([key, value]) => ({
value: key,
label: mappingStats[value.Property].name + " " + mappingStats[value.Property].unit,
imageUrl: `${process.env.CDN_URL}/${mappingStats[value.Property].icon}`
}))}
selectedCustomSet={v.affixId}
placeholder={transI18n("selectASubStat")}
setSelectedCustomSet={(key) => handleSubStatChange(key, index, 0, 0)}
/>
</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 btn-info 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 btn-info 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 btn-info border-0"
>
{calcAffixBonus(subAffixOptions[v.affixId], v.stepCount + 2, v.rollCount + 1)}{mappingStats?.[subAffixOptions[v.affixId]?.Property]?.unit || ""}
</button>
{/* Stat Value Readout */}
<div className="px-3 py-1 rounded-xl bg-base-100 border border-base-content/10 text-right min-w-24 shadow-xs">
<span className="text-sm font-mono font-extrabold text-primary block">
+{calcAffixBonus(subAffixOptions[v.affixId], v.stepCount, v.rollCount)}{mappingStats?.[subAffixOptions[v.affixId]?.Property]?.unit || ""}
</span>
<span className="text-[11px] text-base-content/60 font-mono font-semibold">
Roll: {v.rollCount} | Step: {v.stepCount}
</span>
</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 btn-info 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 btn-info 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 btn-info 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>
{/* Up / Down Steppers */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 pt-0.5">
{/* Up Rolls */}
<div className="space-y-1">
<div className="flex items-center gap-1 text-[11px] font-bold text-success">
<ChevronUp className="w-3.5 h-3.5" />
<span>{transI18n("upRoll")}</span>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2].map((stepOffset) => (
<button
key={stepOffset}
onClick={() => handleSubStatChange(v.affixId, index, v.rollCount + 1, v.stepCount + stepOffset)}
className="btn btn-xs btn-success rounded-xl font-mono font-bold shadow-xs"
>
+{calcAffixBonus(subAffixOptions[v.affixId], v.stepCount + stepOffset, v.rollCount + 1)}
</button>
))}
</div>
</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>
{/* Down Rolls */}
<div className="space-y-1">
<div className="flex items-center gap-1 text-[11px] font-bold text-error">
<ChevronDown className="w-3.5 h-3.5" />
<span>{transI18n("downRoll")}</span>
</div>
<div className="grid grid-cols-3 gap-1.5">
{[0, 1, 2].map((stepOffset) => (
<button
key={stepOffset}
onClick={() => handleSubStatChange(v.affixId, index, Math.max(v.rollCount - 1, 0), Math.max(v.stepCount - stepOffset, 0))}
className="btn btn-xs btn-error rounded-xl font-mono font-bold shadow-xs"
>
-{calcAffixBonus(subAffixOptions[v.affixId], Math.max(v.stepCount - stepOffset, 0), Math.max(v.rollCount - 1, 0))}
</button>
))}
</div>
</div>
</div>
{/* Reset & Rollback actions */}
<div className="flex items-center justify-end gap-2 pt-1 border-t border-base-content/10">
<button
className="btn btn-ghost btn-xs text-warning hover:bg-warning/10 rounded-lg text-xs font-bold"
onClick={() => handlerRollback(index)}
>
{transI18n("rollBack")}
</button>
<button
className="btn btn-ghost btn-xs text-error hover:bg-error/10 rounded-lg text-xs font-bold"
onClick={() => resetSubStat(index)}
>
{transI18n("reset")}
</button>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
</div>
+212 -144
View File
@@ -2,7 +2,6 @@
"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";
@@ -15,9 +14,15 @@ import useCurrentDataStore from "@/stores/currentDataStore";
import useDetailDataStore from "@/stores/detailDataStore";
import { getLocaleName } from '@/helper';
import useLocaleStore from "@/stores/localeStore";
import { useShallow } from 'zustand/react/shallow';
export default function RelicsInfo() {
const { avatars, setAvatars } = useUserDataStore()
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatar: state.setAvatar,
}))
)
const {
setSelectedRelicSlot,
selectedRelicSlot,
@@ -28,18 +33,50 @@ export default function RelicsInfo() {
resetHistory,
resetSubStat,
listSelectedSubStats,
} = useRelicMakerStore()
} = useRelicMakerStore(
useShallow((state) => ({
setSelectedRelicSlot: state.setSelectedRelicSlot,
selectedRelicSlot: state.selectedRelicSlot,
setSelectedMainStat: state.setSelectedMainStat,
setSelectedRelicSet: state.setSelectedRelicSet,
setSelectedRelicLevel: state.setSelectedRelicLevel,
setListSelectedSubStats: state.setListSelectedSubStats,
resetHistory: state.resetHistory,
resetSubStat: state.resetSubStat,
listSelectedSubStats: state.listSelectedSubStats,
}))
)
const {
isOpenRelic,
setIsOpenRelic,
isOpenQuickView,
setIsOpenQuickView
} = useModelStore()
} = useModelStore(
useShallow((state) => ({
isOpenRelic: state.isOpenRelic,
setIsOpenRelic: state.setIsOpenRelic,
isOpenQuickView: state.isOpenQuickView,
setIsOpenQuickView: state.setIsOpenQuickView,
}))
)
const transI18n = useTranslations("DataPage")
const { avatarSelected } = useCurrentDataStore()
const { mapRelicSet, subAffix } = useDetailDataStore()
const { locale } = useLocaleStore()
const { avatarSelected } = useCurrentDataStore(
useShallow((state) => ({
avatarSelected: state.avatarSelected,
}))
)
const { mapRelicSet, subAffix } = useDetailDataStore(
useShallow((state) => ({
mapRelicSet: state.mapRelicSet,
subAffix: state.subAffix,
}))
)
const { locale } = useLocaleStore(
useShallow((state) => ({
locale: state.locale,
}))
)
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
@@ -60,18 +97,33 @@ export default function RelicsInfo() {
const getRelic = useCallback((slot: string) => {
const avatar = avatars[avatarSelected?.ID.toString() || ""];
if (avatar) {
return avatar.profileList[avatar.profileSelect]?.relics[slot] || null;
const profile = avatar.profileList?.[avatar.profileSelect] ?? avatar.profileList?.[0];
return profile?.relics?.[slot] || null;
}
return null;
}, [avatars, avatarSelected]);
const handlerDeleteRelic = (slot: string) => {
const avatar = avatars[avatarSelected?.ID.toString() || ""];
const avatarKey = avatarSelected?.ID.toString() || "";
const avatar = avatars[avatarKey];
if (avatar) {
delete avatar.profileList[avatar.profileSelect].relics[slot]
setAvatars({ ...avatars });
const profileIndex = avatar.profileSelect;
const currentProfile = avatar.profileList?.[profileIndex];
if (currentProfile) {
const newRelics = { ...currentProfile.relics };
delete newRelics[slot];
const newProfileList = [...avatar.profileList];
newProfileList[profileIndex] = {
...currentProfile,
relics: newRelics,
};
setAvatar({
...avatar,
profileList: newProfileList,
});
}
}
}
};
const handlerChangeRelic = (slot: string) => {
const relic = getRelic(slot)
@@ -84,10 +136,12 @@ export default function RelicsInfo() {
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 = subAffix["5"][item.sub_affix_id.toString()]?.Property || "";
newSubAffixes[index].rollCount = item.count || 0;
newSubAffixes[index].stepCount = item.step || 0;
if (newSubAffixes[index]) {
newSubAffixes[index].affixId = item.sub_affix_id.toString();
newSubAffixes[index].property = subAffix["5"]?.[item.sub_affix_id.toString()]?.Property || "";
newSubAffixes[index].rollCount = item.count || 0;
newSubAffixes[index].stepCount = item.step || 0;
}
})
setListSelectedSubStats(newSubAffixes)
} else {
@@ -111,12 +165,11 @@ export default function RelicsInfo() {
const relicEffects = useMemo(() => {
const avatar = avatars[avatarSelected?.ID.toString() || ""];
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 currentProfile = avatar?.profileList?.[avatar?.profileSelect] ?? avatar?.profileList?.[0];
if (currentProfile?.relics) {
for (const relic of Object.values(currentProfile.relics)) {
if (relic) {
relicCount[relic.relic_set_id] = (relicCount[relic.relic_set_id] || 0) + 1;
}
}
}
@@ -132,7 +185,7 @@ export default function RelicsInfo() {
const modalConfigs: ModalConfig[] = [
{
id: "action_detail_modal",
title: "",
title: transI18n("changeRelic") || "Cài đặt di vật",
isOpen: isOpenRelic,
onClose: () => {
setIsOpenRelic(false)
@@ -172,48 +225,66 @@ export default function RelicsInfo() {
}, [isOpenRelic]);
return (
<div className="max-h-[77vh] min-h-[50vh] overflow-y-scroll overflow-x-hidden">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="w-full space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
{/* Left Section - Relic Equipment Grid */}
<div className="lg:col-span-2 space-y-4">
<div className="glass-card rounded-2xl p-4 sm:p-6 border border-base-content/10 shadow-xs space-y-6">
<div className="flex items-center justify-between pb-3 border-b border-base-content/10">
<h2 className="text-lg sm:text-xl font-bold flex items-center gap-2.5">
<div className="w-1.5 h-5 bg-linear-to-b from-primary to-accent rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-primary to-accent font-bold tracking-tight">
{transI18n("relics")}
</span>
</h2>
{/* 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>
<button
onClick={() => {
setIsOpenQuickView(true)
handleShow("quick_view_modal")
}}
className="btn btn-sm btn-outline btn-primary rounded-xl font-bold shadow-xs"
>
{transI18n("quickView")}
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
{/* 6-Slot Grid completely free of box-in-box */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6 py-4">
{["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.toString() || ""}
/>
</div>
<div
key={index}
onClick={() => {
if (item === selectedRelicSlot) {
setSelectedRelicSlot("")
} else {
setSelectedRelicSlot(item)
}
handlerChangeRelic(item)
}}
className={`relative group flex flex-col items-center justify-center p-2 rounded-2xl transition-all duration-200 cursor-pointer ${
item === selectedRelicSlot
? "bg-primary/5 rounded-2xl ring-1.5 ring-primary/40"
: "hover:bg-base-content/5"
}`}
>
<RelicCard
slot={item}
avatarId={avatarSelected?.ID.toString() || ""}
/>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex gap-1">
{/* Micro Actions */}
<div className="absolute top-0 right-0 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"
className="btn btn-xs btn-circle btn-primary shadow-xs"
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" />
<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>
@@ -225,11 +296,11 @@ export default function RelicsInfo() {
handlerDeleteRelic(item)
}
}}
className="btn btn-error p-1.5 rounded-full shadow-lg transition-colors duration-200"
className="btn btn-xs btn-circle btn-error shadow-xs"
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" />
<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>
)}
@@ -237,13 +308,13 @@ export default function RelicsInfo() {
</div>
))}
</div>
<div className="grid grid-cols-2 gap-2 mt-10">
{/* Bottom Actions Bar */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button
disabled={!selectedRelicSlot}
onClick={() => {
handlerChangeRelic(selectedRelicSlot)
}}
className="btn btn-info"
onClick={() => handlerChangeRelic(selectedRelicSlot)}
className="btn btn-primary btn-sm rounded-xl font-bold shadow-xs"
>
{transI18n("changeRelic")}
</button>
@@ -255,112 +326,109 @@ export default function RelicsInfo() {
handlerDeleteRelic(selectedRelicSlot)
}
}}
className="btn btn-error"
className="btn btn-ghost btn-sm text-error hover:bg-error/20 rounded-xl"
>
{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 = mapRelicSet[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(
getLocaleName(locale, relicInfo.Name),
[]
)
}}
/>
{setEffect.count && (
<span className={`text-sm text-info`}>
({setEffect.count})
</span>
)}
</div>
<div className="space-y-2 pl-4">
{Object.entries(relicInfo.Skills).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(
getLocaleName(locale, value.Desc),
value.Param || []
)
}}
/>
</div>
)
})}
</div>
</div>
)
})}
{/* Right Section - Set Effects Showcase */}
<div className="space-y-4">
<div className="glass-card rounded-2xl p-4 sm:p-6 border border-base-content/10 shadow-xs space-y-4">
<div className="pb-3 border-b border-base-content/10">
<h3 className="text-lg sm:text-xl font-bold flex items-center gap-2.5">
<div className="w-1.5 h-5 bg-linear-to-b from-amber-400 to-amber-600 rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-amber-400 to-amber-600 font-bold tracking-tight">
{transI18n("setEffects")}
</span>
</h3>
</div>
{relicEffects.length > 0 ? (
<div className="divide-y divide-base-content/10">
{relicEffects.map((setEffect, index) => {
const relicInfo = mapRelicSet[setEffect.key];
if (!relicInfo) return null;
return (
<div key={index} className="py-3.5 first:pt-0 last:pb-0 space-y-2">
<div className="flex items-center justify-between">
<div
className="font-bold text-sm text-amber-500"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, relicInfo.Name),
[]
)
}}
/>
{setEffect.count && (
<span className="badge badge-primary badge-sm font-mono font-bold">
{setEffect.count} Pc
</span>
)}
</div>
<div className="space-y-2 pt-1">
{Object.entries(relicInfo.Skills).map(([requireNum, value]) => {
if (Number(requireNum) > Number(setEffect.count)) return null;
return (
<div key={requireNum} className="text-xs space-y-0.5 pl-2 border-l-2 border-primary/40">
<div className="font-bold text-emerald-500">
{requireNum}-PC Bonus
</div>
<div
className="text-base-content/80 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, value.Desc),
value.Param || []
)
}}
/>
</div>
)
})}
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-10 text-base-content/40 text-xs italic">
{transI18n("noRelicEquipped")}
</div>
)}
</div>
</div>
</div>
{/* Modals */}
{modalConfigs.map(({ id, title, onClose, content }) => (
<dialog key={id} id={id} className="modal">
<div className="modal-box w-11/12 max-w-[90%] 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"
<div className={`modal-box ${id === 'quick_view_modal' ? 'w-11/12 max-w-4xl' : 'w-11/12 max-w-[92vw] sm:max-w-[88vw]'} max-h-[90vh] bg-base-100 text-base-content border border-base-content/10 shadow-2xl rounded-2xl p-0 overflow-hidden`}>
<div className="sticky top-0 z-20 flex items-center justify-between px-6 py-4 border-b border-base-content/10 bg-base-100">
<h3 className="font-bold text-xl tracking-tight bg-clip-text text-transparent bg-linear-to-r from-primary via-accent to-secondary">
{title}
</h3>
<button
className="btn btn-sm btn-circle btn-ghost text-base-content/60 hover:text-base-content hover:bg-base-200 transition-colors"
onClick={onClose}
>
✕
</motion.button>
</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 className="p-4 sm:p-6 overflow-y-auto max-h-[calc(90vh-5rem)]">
{content}
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
))}
</div>
);
}
+31 -7
View File
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic"
import type { SingleValue } from "react-select"
import Image from "next/image"
import useLocaleStore from "@/stores/localeStore"
import { useShallow } from "zustand/react/shallow"
import ParseText from "../parseText"
import { themeColors } from "@/constant/constant"
import type { Props as SelectProps } from "react-select"
@@ -32,7 +33,12 @@ type SelectCustomProp = {
export default function SelectCustomImage({ customSet, excludeSet, selectedCustomSet, placeholder, setSelectedCustomSet }: SelectCustomProp) {
const options: SelectOption[] = customSet
const { locale, theme } = useLocaleStore()
const { locale, theme } = useLocaleStore(
useShallow((state) => ({
locale: state.locale,
theme: state.theme,
}))
)
const c = themeColors[theme] || themeColors.winter
@@ -42,10 +48,11 @@ export default function SelectCustomImage({ customSet, excludeSet, selectedCusto
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
padding: '10px 14px',
backgroundColor: s.isFocused ? c.bgHover : c.bg,
color: c.text,
cursor: 'pointer'
cursor: 'pointer',
opacity: 1,
}),
singleValue: (p: any) => ({
@@ -53,26 +60,43 @@ export default function SelectCustomImage({ customSet, excludeSet, selectedCusto
display: 'flex',
alignItems: 'center',
gap: '8px',
color: c.text
color: c.text,
opacity: 1,
}),
control: (p: any) => ({
...p,
backgroundColor: c.bg,
borderColor: c.border,
boxShadow: 'none'
boxShadow: 'none',
opacity: 1,
minHeight: '38px',
borderRadius: '12px',
}),
menu: (p: any) => ({
...p,
backgroundColor: c.bg,
color: c.text,
zIndex: 9999
zIndex: 99999,
opacity: 1,
border: `1px solid ${c.border}`,
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3)',
borderRadius: '12px',
overflow: 'hidden',
}),
menuList: (p: any) => ({
...p,
backgroundColor: c.bg,
padding: 0,
opacity: 1,
}),
menuPortal: (p: any) => ({
...p,
zIndex: 9999
zIndex: 99999,
opacity: 1,
})
}
+7 -2
View File
@@ -7,6 +7,7 @@ import useLocaleStore from '@/stores/localeStore'
import { themeColors } from '@/constant/constant'
import type { Props as SelectProps } from "react-select"
import { JSX } from "react"
import { useShallow } from "zustand/react/shallow"
const Select = dynamic(
() => import("react-select").then(m => m.default),
@@ -32,7 +33,9 @@ type SelectCustomProp = {
export default function SelectCustomText({ customSet, excludeSet, selectedCustomSet, placeholder, setSelectedCustomSet }: SelectCustomProp) {
const options: SelectOption[] = customSet
const { theme } = useLocaleStore()
const { theme } = useLocaleStore(
useShallow((state) => ({ theme: state.theme }))
)
const c = themeColors[theme] || themeColors.winter
const customStyles = {
@@ -87,7 +90,9 @@ export default function SelectCustomText({ customSet, excludeSet, selectedCustom
)
}}
/>
{option.time && <div className='text-base'>{option.time}</div>}
{option.time && !option.time.includes("undefined") && (
<div className='text-sm opacity-70'>{option.time}</div>
)}
{option.description && <div
className="text-base"
dangerouslySetInnerHTML={{
+65 -36
View File
@@ -14,16 +14,33 @@ import RelicShowcase from './relicShowcase';
import useDetailDataStore from '@/stores/detailDataStore';
import useCurrentDataStore from '@/stores/currentDataStore';
import { getLocaleName } from '@/helper';
import { useShallow } from 'zustand/react/shallow';
export default function ShowCaseInfo() {
const { avatarSelected } = useCurrentDataStore()
const { mainAffix, subAffix, mapRelicSet, mapAvatar, mapLightCone, baseType, damageType } = useDetailDataStore()
const { avatars } = useUserDataStore()
const { avatarSelected } = useCurrentDataStore(
useShallow((state) => ({ avatarSelected: state.avatarSelected }))
)
const { mainAffix, subAffix, mapRelicSet, mapAvatar, mapLightCone, baseType, damageType } = useDetailDataStore(
useShallow((state) => ({
mainAffix: state.mainAffix,
subAffix: state.subAffix,
mapRelicSet: state.mapRelicSet,
mapAvatar: state.mapAvatar,
mapLightCone: state.mapLightCone,
baseType: state.baseType,
damageType: state.damageType,
}))
)
const { avatars } = useUserDataStore(
useShallow((state) => ({ avatars: state.avatars }))
)
const [avgColor, setAvgColor] = useState('#222');
const imgRef = useRef(null);
const cardRef = useRef(null)
const { locale } = useLocaleStore()
const { locale } = useLocaleStore(
useShallow((state) => ({ locale: state.locale }))
)
const transI18n = useTranslations("DataPage")
const handleSaveImage = useCallback(() => {
@@ -88,11 +105,11 @@ export default function ShowCaseInfo() {
const avatarProfile = useMemo(() => {
if (!avatarSelected || !avatarData) return
return avatarData?.profileList?.[avatarData?.profileSelect]
return avatarData?.profileList?.[avatarData?.profileSelect] ?? avatarData?.profileList?.[0]
}, [avatarSelected, avatarData])
const lightconeStats = useMemo(() => {
if (!avatarSelected || !avatarProfile?.lightcone || !mapLightCone[avatarProfile?.lightcone?.item_id]) return
if (!avatarSelected || !avatarProfile?.lightcone || !mapLightCone?.[avatarProfile?.lightcone?.item_id]) return
const promotion = calcPromotion(avatarProfile?.lightcone?.level)
const atkStat = calcBaseStat(
mapLightCone[avatarProfile?.lightcone?.item_id]?.Stats?.[promotion]?.BaseAttack,
@@ -122,12 +139,11 @@ export default function ShowCaseInfo() {
const relicEffects = useMemo(() => {
const avatar = avatars[avatarSelected?.ID?.toString() || ""];
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 profile = avatar?.profileList?.[avatar?.profileSelect] ?? avatar?.profileList?.[0];
if (profile?.relics) {
for (const relic of Object.values(profile.relics)) {
if (relic && relic.relic_set_id) {
relicCount[relic.relic_set_id] = (relicCount[relic.relic_set_id] || 0) + 1;
}
}
}
@@ -497,21 +513,34 @@ export default function ShowCaseInfo() {
}, [])
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="w-full space-y-4">
<div className="bg-base-100 rounded-2xl p-4 sm:p-6 border border-base-300 shadow-sm space-y-6">
{/* Header */}
<div className="flex items-center justify-between pb-3 border-b border-base-content/10 flex-wrap gap-3">
<h2 className="text-xl sm:text-2xl font-extrabold flex items-center gap-2.5">
<div className="w-2 h-6 bg-linear-to-b from-primary to-accent rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-primary to-accent">
{transI18n("showcaseCard")}
</span>
</h2>
<button
className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs active-press"
onClick={handleSaveImage}
>
{transI18n("save")}
</button>
</div>
<div className="overflow-auto">
<div
ref={cardRef}
className=" relative min-h-162.5 w-400 rounded-3xl transition-all duration-500 overflow-hidden"
style={{
backgroundColor: `${applyBrightness(avgColor, 0.3)}`,
backdropFilter: "blur(50px)",
WebkitBackdropFilter: "blur(50px)",
}}
>
<div className="overflow-auto rounded-2xl">
<div
ref={cardRef}
className="relative min-h-162.5 w-400 rounded-3xl transition-all duration-500 overflow-hidden text-white"
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>
@@ -630,9 +659,9 @@ export default function ShowCaseInfo() {
</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">
<div className="flex items-center justify-center min-w-8 h-8 px-1.5 rounded-full text-base font-bold text-[#E6D5B5] bg-[#5c4022] border-2 border-[#d8b46e] shadow-[0_0_10px_rgba(250,204,21,0.3)] select-none">
{totalSubStats}
</span>
</div>
{avatarSelected && (
<div className="flex gap-1">
@@ -921,12 +950,12 @@ export default function ShowCaseInfo() {
</div>
<div className="flex h-162.5 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-125">
<div className="flex h-162.5 w-1/3 flex-col justify-between py-3 z-10 text-white">
<div className="flex w-full flex-col justify-between gap-y-0.5 text-base h-125 text-white">
{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 key={index} className="flex flex-row items-center justify-between text-white">
<div className="flex flex-row items-center">
<NextImage src={`${process.env.CDN_URL}/${stat?.icon}`}
unoptimized
@@ -936,16 +965,16 @@ export default function ShowCaseInfo() {
height={40}
className="h-10 w-10 p-2"
/>
<span className="font-bold">{stat.name}</span>
<span className="font-bold text-white">{stat.name}</span>
</div>
<div className="ml-3 mr-3 grow border rounded opacity-50" />
<div className="flex cursor-default flex-col text-right font-bold">{
<div className="ml-3 mr-3 grow border-b border-white/20" />
<div className="flex cursor-default flex-col text-right font-bold text-white">{
stat.value ? stat.unit === "%" ? (stat.value * 100).toFixed(stat.round) : stat.value.toFixed(stat.round) : 0
}{stat.unit}</div>
</div>
)
})}
<hr />
<hr className="border-white/20" />
</div>
<div className="flex flex-col items-center gap-1 w-full my-2">
@@ -1000,7 +1029,7 @@ export default function ShowCaseInfo() {
</div>
</div>
</div>
</div>
);
}
+71 -85
View File
@@ -1,6 +1,5 @@
"use client"
import NextImage from "next/image"
import { AvatarDetail, RelicShowcaseType } from "@/types";
@@ -12,97 +11,84 @@ export default function RelicShowcase({
avatarInfo: AvatarDetail;
}) {
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 items-center gap-3 py-2 px-1 border-b border-base-content/10 last:border-b-0">
{/* Relic Figure Icon */}
<div className="relative shrink-0 flex flex-col items-center">
<NextImage
src={relic?.img || ""}
unoptimized
crossOrigin="anonymous"
width={48}
height={48}
alt="Relic"
className="h-12 w-12 object-contain"
/>
</div>
<div className="flex relative">
<div className="absolute inset-0 rounded-lg blur-lg -z-10"></div>
<NextImage
src={relic?.img || ""}
unoptimized
crossOrigin="anonymous"
width={78}
height={78}
alt="Relic Icon"
className="h-19.5 w-19.5 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>
{/* Main Affix - Clean Display */}
<div className="flex w-20 shrink-0 flex-col items-start justify-center">
<div className="flex items-center gap-1">
{relic?.mainAffix?.detail?.icon ? (
<NextImage
src={`${process.env.CDN_URL}/${relic?.mainAffix?.detail?.icon}` || ""}
src={`${process.env.CDN_URL}/${relic.mainAffix.detail.icon}`}
unoptimized
crossOrigin="anonymous"
width={35}
height={35}
alt="Main Affix Icon"
className="h-8.75 w-8.75"
width={16}
height={16}
alt="Main Affix"
className="h-4 w-4 theme-stat-icon shrink-0 object-contain"
/>
</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}
) : null}
<span className="text-xs font-bold text-amber-600 dark:text-amber-400 font-mono">
{relic?.mainAffix?.valueAffix + (relic?.mainAffix?.detail?.unit || "")}
</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={`${process.env.CDN_URL}/${subAffix?.detail?.icon}` || ""}
unoptimized
crossOrigin="anonymous"
width={32}
height={32}
alt="Sub Affix Icon"
className="h-6 w-6 shrink-0"
/>
) : (
<div className="h-6 w-6 bg-black/60 rounded flex items-center justify-center border border-white/10 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 shrink-0 leading-none">
{subAffix?.count}
</span>
)}
</div>
</div>
)
})}
</div>
<span className="text-[10px] font-mono text-base-content/50 ml-0.5">
+{relic?.mainAffix?.level}
</span>
</div>
</>
{/* Sub Affixes Grid - Clean Flat Typography */}
<div className="grid flex-1 grid-cols-2 gap-x-3 gap-y-1">
{relic?.subAffix?.map((subAffix, index) => {
if (!subAffix) return null
const isOptimalStat = avatarInfo?.Relics?.SubAffixPropertyList?.includes(subAffix?.property)
return (
<div
key={index}
className={`flex items-center justify-between py-0.5 min-w-0 ${
isOptimalStat
? "text-amber-600 dark:text-amber-400 font-semibold"
: "text-base-content/75"
}`}
>
<div className="flex items-center gap-1 min-w-0 flex-1">
{subAffix?.detail?.icon ? (
<NextImage
src={`${process.env.CDN_URL}/${subAffix.detail.icon}`}
unoptimized
crossOrigin="anonymous"
width={14}
height={14}
alt="Sub Affix"
className="h-3.5 w-3.5 theme-stat-icon shrink-0 object-contain"
/>
) : null}
<span className="text-xs font-mono truncate">
+{subAffix?.valueAffix + (subAffix?.detail?.unit || "")}
</span>
</div>
{isOptimalStat && subAffix?.count ? (
<span className="ml-1 text-[10px] font-bold text-amber-600/80 dark:text-amber-400/80 shrink-0 font-mono">
({subAffix?.count})
</span>
) : null}
</div>
)
})}
</div>
</div>
)
}
+248 -230
View File
@@ -12,13 +12,26 @@ import { toast } from "react-toastify";
import useCurrentDataStore from "@/stores/currentDataStore";
import { StatusAdd } from '@/types/avatarDetail';
import { SkillDescription } from "./skillDescription";
import { useShallow } from "zustand/react/shallow";
export default function SkillsInfo() {
const transI18n = useTranslations("DataPage")
const { theme } = useLocaleStore()
const { avatarSelected, skillIDSelected, setSkillIDSelected } = useCurrentDataStore()
const { avatars, setAvatar } = useUserDataStore()
const { locale } = useLocaleStore()
const { theme, locale } = useLocaleStore(
useShallow((state) => ({ theme: state.theme, locale: state.locale }))
)
const { avatarSelected, skillIDSelected, setSkillIDSelected } = useCurrentDataStore(
useShallow((state) => ({
avatarSelected: state.avatarSelected,
skillIDSelected: state.skillIDSelected,
setSkillIDSelected: state.setSkillIDSelected,
}))
)
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
setAvatar: state.setAvatar,
}))
)
const traceButtons = useMemo(() => {
if (!avatarSelected) return
return traceButtonsInfo[avatarSelected.BaseType]
@@ -116,268 +129,273 @@ export default function SkillsInfo() {
}
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 && avatarSelected && (
<div className="grid col-span-4 relative w-full aspect-square">
<Image
unoptimized
crossOrigin="anonymous"
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 (!avatarSelected?.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" : ""}
${btn.size === "elation" ? "w-[9vw] h-[9vw] md:w-[3.5vw] md:h-[3.5vw] bg-black" : ""}
${skillIDSelected === btn.id ? "border-4 border-primary" : ""}
${!avatarData?.data.skills?.[avatarSkillTree?.[btn.id]?.["1"]?.PointID]
? "opacity-50 cursor-not-allowed"
: ""}
`}
onClick={() => {
setSkillIDSelected(btn.id === skillIDSelected ? null : btn.id)
}}
style={{
left: btn.left,
top: btn.top,
transform: "translate(-50%, -50%)",
}}
>
<Image
src={`${process.env.CDN_URL}/${avatarSelected?.SkillTrees?.[btn.id]?.["1"]?.Icon}`}
alt={btn.id.replaceAll("Point", "")}
priority={true}
unoptimized
crossOrigin="anonymous"
width={124}
height={124}
style={{
objectFit: "contain",
padding: "2px",
filter: (btn.size !== "big" && btn.size !== "memory" && btn.size !== "elation") ? "brightness(0%)" : ""
<div className="w-full space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6">
{/* Left Panel: Skill Tree Visualizer */}
<div className="lg:col-span-6 space-y-4">
<div className="glass-card rounded-2xl p-4 sm:p-6 border border-base-300 shadow-sm space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-base-300">
<h2 className="text-lg sm:text-xl font-bold flex items-center gap-2.5">
<div className="w-1.5 h-5 bg-linear-to-b from-primary to-accent rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-primary to-accent font-bold tracking-tight">
{transI18n("skills")}
</span>
</h2>
<button
className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs active-press"
onClick={handlerMaxAll}
>
{transI18n("maxAll")}
</button>
</div>
{/* Visual Skill Tree Container */}
<div className="flex justify-center items-center py-2">
{traceButtons && avatarSelected && (
<div className="relative w-full max-w-md aspect-square">
<Image
unoptimized
crossOrigin="anonymous"
src={`/skilltree/${avatarSelected?.BaseType?.toUpperCase()}.webp`}
alt="Skill Tree"
width={312}
priority={true}
height={312}
style={{
filter: (theme === "winter" || theme === "cupcake") ? "invert(1)" : "none"
}}
className="w-full h-full object-cover"
/>
{traceButtons.map((btn, index) => {
if (!avatarSelected?.SkillTrees?.[btn.id]) {
return null
}
const isSelected = skillIDSelected === btn.id;
const isLearned = !!avatarData?.data.skills?.[avatarSkillTree?.[btn.id]?.["1"]?.PointID];
const isMajor = btn.size === "big" || btn.size === "memory" || btn.size === "elation";
return (
<div
key={`${btn.id}-${index}`}
id={btn.id}
className={`
absolute rounded-full border border-black/70 cursor-pointer transition-all duration-150 ease-out flex justify-center items-center select-none overflow-visible
${btn.size === "small" ? "w-[6.5vw] h-[6.5vw] md:w-[2.2vw] md:h-[2.2vw] bg-white" : ""}
${btn.size === "medium" ? "w-[8.5vw] h-[8.5vw] md:w-[3vw] md:h-[3vw] bg-white" : ""}
${btn.size === "big" ? "w-[9.5vw] h-[9.5vw] md:w-[3.5vw] md:h-[3.5vw] bg-black" : ""}
${btn.size === "special" ? "w-[9.5vw] h-[9.5vw] md:w-[3.5vw] md:h-[3.5vw] bg-white" : ""}
${btn.size === "memory" ? "w-[9.5vw] h-[9.5vw] md:w-[3.5vw] md:h-[3.5vw] bg-black" : ""}
${btn.size === "elation" ? "w-[9.5vw] h-[9.5vw] md:w-[3.5vw] md:h-[3.5vw] bg-black" : ""}
${isSelected ? "ring-3 ring-cyan-400 ring-offset-2 ring-offset-base-100 shadow-[0_0_12px_rgba(34,211,238,0.8)] z-20" : "hover:ring-2 hover:ring-primary/40 z-10"}
${!isLearned ? "opacity-40 grayscale" : "shadow-[0_0_6px_rgba(255,255,255,0.4)]"}
`}
onClick={() => {
setSkillIDSelected(btn.id === skillIDSelected ? null : btn.id)
}}
/>
{(btn.size === "big" || btn.size === "memory" || btn.size === "elation") && (
<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={{
left: btn.left,
top: btn.top,
transform: "translate(-50%, -50%)",
}}
>
<Image
src={`${process.env.CDN_URL}/${avatarSelected?.SkillTrees?.[btn.id]?.["1"]?.Icon}`}
alt={btn.id.replaceAll("Point", "")}
priority={true}
unoptimized
crossOrigin="anonymous"
width={124}
height={124}
style={{
position: "absolute",
inset: 0,
backgroundColor: "#4a6eff",
mixBlendMode: "screen",
opacity: 0.8,
borderRadius: "50%"
objectFit: "contain",
padding: "2px",
filter: !isMajor ? "brightness(0%)" : ""
}}
/>
)}
{btn.size === "memory" && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "#9a89ff",
mixBlendMode: "screen",
opacity: 0.4,
borderRadius: "50%"
}}
/>
)}
{btn.size === "elation" && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "#ff8c00",
mixBlendMode: "screen",
opacity: 0.5,
borderRadius: "50%"
}}
/>
)}
{btn.size === "big" && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "#f5e4b0",
mixBlendMode: "screen",
opacity: 0.3,
borderRadius: "50%"
}}
/>
)}
</div>
{/* Skill Type Color Overlays */}
{btn.size === "special" && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
backgroundColor: "#4a6eff",
mixBlendMode: "screen",
opacity: 0.8,
}}
/>
)}
{btn.size === "memory" && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
backgroundColor: "#9a89ff",
mixBlendMode: "screen",
opacity: 0.45,
}}
/>
)}
{btn.size === "elation" && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
backgroundColor: "#ff8c00",
mixBlendMode: "screen",
opacity: 0.5,
}}
/>
)}
{btn.size === "big" && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
style={{
backgroundColor: "#f5e4b0",
mixBlendMode: "screen",
opacity: 0.35,
}}
/>
)}
)
})}
</div>
)}
{!traceButtons && avatarSelected && (
<div className="flex flex-col relative w-full aspect-square">
</div>
)}
{isMajor && (
<p className="z-12 text-[10px] sm:text-xs font-mono font-bold text-center rounded-full absolute translate-y-full mt-0.5 bg-black/80 text-white px-1.5 py-0.2 border border-white/20 left-1/2 -translate-x-1/2 shadow-xs whitespace-nowrap">
{`${avatarData?.data.skills?.[avatarSkillTree?.[btn.id]?.["1"]?.PointID] || 0}/${avatarSkillTree?.[btn.id]?.["1"]?.MaxLevel}`}
</p>
)}
</div>
)
})}
</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>
{skillIDSelected && avatarSelected?.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>
))}
{/* Right Panel: Skill Details */}
<div className="lg:col-span-6 space-y-4">
<div className="glass-card rounded-2xl p-4 sm:p-6 border border-base-300 shadow-sm space-y-5">
<div className="pb-3 border-b border-base-300">
<h2 className="text-lg sm:text-xl font-bold flex items-center gap-2.5">
<div className="w-1.5 h-5 bg-linear-to-b from-secondary to-primary rounded-full"></div>
<span className="bg-clip-text text-transparent bg-linear-to-r from-secondary to-primary font-bold tracking-tight">
{transI18n("details")}
</span>
</h2>
</div>
{skillIDSelected && avatarSelected?.SkillTrees && avatarData ? (
<div className="space-y-4">
{/* Level Slider */}
{skillInfo?.MaxLevel && skillInfo?.MaxLevel > 1 ? (
<div className="space-y-1.5 pb-2">
<div className="text-sm font-bold text-base-content/80">
{transI18n("level")}
</div>
<div className="w-full max-w-sm space-y-1">
<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-primary range-xs w-full"
step="1"
/>
<div className="flex justify-between px-1 text-[11px] font-mono text-base-content/50">
{Array.from({ length: skillInfo?.MaxLevel || 1 }, (_, index) => index + 1).map((idx) => (
<span key={idx}>{idx}</span>
))}
</div>
</div>
</div>
</div>
) : skillInfo?.MaxLevel && skillInfo?.MaxLevel === 1 && traceButtons?.find((btn) => btn.id === skillIDSelected)?.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 === skillIDSelected)?.size === "special") {
if (e.target.checked) {
) : skillInfo?.MaxLevel && skillInfo?.MaxLevel === 1 && traceButtons?.find((btn) => btn.id === skillIDSelected)?.size !== "big" ? (
<div className="flex items-center gap-3 pb-2">
<span className="text-sm font-bold text-base-content/80">{transI18n("status")}</span>
<input
type="checkbox"
checked={avatarData?.data.skills?.[skillInfo?.PointID] === 1}
className="toggle toggle-primary toggle-sm"
onChange={(e) => {
if (traceButtons?.find((btn) => btn.id === skillIDSelected)?.size === "special") {
if (e.target.checked) {
const newData = structuredClone(avatarData)
newData.data.skills[skillInfo?.PointID] = 1
setAvatar(newData)
return
}
const newData = structuredClone(avatarData)
newData.data.skills[skillInfo?.PointID] = 1
delete newData.data.skills[skillInfo?.PointID]
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")}
handlerChangeStatusTrace(e.target.checked)
}}
/>
<span className={`text-xs font-bold ${avatarData?.data.skills?.[skillInfo?.PointID] === 1 ? 'text-primary' : 'text-base-content/40'}`}>
{avatarData?.data.skills?.[skillInfo?.PointID] === 1 ? transI18n("active") : transI18n("inactive")}
</span>
</div>
</div>
) : (
null
)}
) : null}
{((skillInfo?.PointName && skillInfo?.PointDesc) ||
(skillInfo?.PointName && skillInfo?.StatusAddList.length > 0))
&& (
<div className="text-xl font-bold flex items-center gap-2 mt-2">
{getLocaleName(locale, skillInfo.PointName)}
{/* Point Name & Stat Additions */}
{((skillInfo?.PointName && skillInfo?.PointDesc) ||
(skillInfo?.PointName && skillInfo?.StatusAddList.length > 0)) && (
<div className="space-y-1.5">
<h3 className="text-lg font-extrabold text-base-content">
{getLocaleName(locale, skillInfo.PointName)}
</h3>
{skillInfo.StatusAddList.length > 0 && (
<div>
<div className="flex flex-wrap gap-1.5">
{skillInfo.StatusAddList.map((status, index) => (
<div key={index}>
<div className="text-xl font-bold">{getTraceBuffDisplay(status)}</div>
</div>
<span key={index} className="text-xs font-mono font-bold px-2 py-0.5 rounded-lg bg-primary/10 text-primary border border-primary/20">
+{getTraceBuffDisplay(status)}
</span>
))}
</div>
)}
</div>
)}
{skillInfo?.PointDesc && (
<div
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, skillInfo?.PointDesc) || "",
skillInfo?.Param || []
)
}}
/>
)}
{skillInfo?.LevelUpSkillID
&& skillInfo?.LevelUpSkillID.length > 0
&& dataLevelUpSkill
&& (
<div className="mt-2 flex flex-col gap-2">
{/* Skill Description Prose */}
{skillInfo?.PointDesc && (
<div
className="text-xs sm:text-sm text-base-content/85 leading-relaxed"
dangerouslySetInnerHTML={{
__html: replaceByParam(
getLocaleName(locale, skillInfo?.PointDesc) || "",
skillInfo?.Param || []
)
}}
/>
)}
{/* Level-up Skill Items (Major / Servant Skills) */}
{skillInfo?.LevelUpSkillID && skillInfo?.LevelUpSkillID.length > 0 && dataLevelUpSkill && (
<div className="space-y-6 pt-1">
{dataLevelUpSkill?.data?.map((skill, index) => (
<div key={index}>
<div className="text-xl font-bold text-primary">
{transI18n(dataLevelUpSkill.isServant ? `${skill?.AttackType ? "severaltalent" : "servantskill"}` : `${skill?.AttackType ? skill?.AttackType.toLowerCase() : "talent"}`)}
{` (${transI18n(skill?.SkillEffect?.toLowerCase())})`}
</div>
<div key={index} className={index > 0 ? "border-t border-base-content/10 pt-4" : ""}>
<SkillDescription
skill={skill}
locale={locale}
avatarData={avatarData}
skillInfo={skillInfo}
attackTypeLabel={transI18n(dataLevelUpSkill.isServant ? (skill?.AttackType ? "severaltalent" : "servantskill") : (skill?.AttackType ? skill?.AttackType.toLowerCase() : "talent"))}
skillEffectLabel={skill?.SkillEffect ? transI18n(skill.SkillEffect.toLowerCase()) : undefined}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
) : (
<div className="text-center py-16 text-base-content/40 text-sm italic">
Select a trace node from the skill tree to configure
</div>
)}
</div>
</div>
</div>
</div>
+34 -17
View File
@@ -2,37 +2,54 @@ import { getLocaleName, replaceByParam } from "@/helper";
import { AvatarStore, SkillDetail, SkillTreePoint } from "@/types";
import ExtraEffectList from "../extraInfo";
export const SkillDescription = ({ skill, locale, avatarData, skillInfo }: {
export const SkillDescription = ({ skill, locale, avatarData, skillInfo, attackTypeLabel, skillEffectLabel }: {
skill: SkillDetail,
locale: string,
avatarData: AvatarStore,
skillInfo: SkillTreePoint
skillInfo: SkillTreePoint,
attackTypeLabel?: string,
skillEffectLabel?: string
}) => {
const levelKey = avatarData?.data.skills?.[skillInfo?.PointID]?.toString() || "";
const params = skill.Level[levelKey]?.Param || [];
const descHtml = getLocaleName(locale, skill.Desc) || getLocaleName(locale, skill.SimpleDesc);
const extraList = Object.values(skill.Extra).length > 0
const extraList = Object.values(skill.Extra || {}).length > 0
? skill.Extra
: skill?.SimpleExtra || {};
return (
<div className="flex flex-col gap-2">
<div className="space-y-2 pb-2">
<div className="flex items-center gap-2">
<div className="w-1 h-5 bg-primary/80 rounded-sm" />
<div
className="text-lg font-bold tracking-wide text-foreground uppercase"
dangerouslySetInnerHTML={{ __html: replaceByParam(getLocaleName(locale, skill.Name), []) }}
/>
</div>
const skillName = getLocaleName(locale, skill.Name);
<div
className="text-[15px] leading-relaxed text-foreground/90 pl-3 border-l border-transparent"
dangerouslySetInnerHTML={{ __html: replaceByParam(descHtml, params) }}
/>
return (
<div className="space-y-2.5">
{/* Skill Name Heading */}
<h3 className="text-xl font-bold text-base-content tracking-tight">
{skillName}
</h3>
{/* Attack Type & Effect Tag */}
{(attackTypeLabel || skillEffectLabel) && (
<div className="text-sm font-bold text-primary flex items-center gap-1.5">
<span>{attackTypeLabel}</span>
{skillEffectLabel && (
<span className="text-primary/70 font-medium">({skillEffectLabel})</span>
)}
</div>
)}
{/* Skill Subtitle with accent bar */}
<div className="flex items-center gap-2 text-sm font-bold text-primary uppercase tracking-wide">
<span className="w-1 h-3.5 bg-primary rounded-xs"></span>
<span>{skillName}</span>
</div>
{/* Description */}
<div
className="text-xs sm:text-sm leading-relaxed text-base-content/85"
dangerouslySetInnerHTML={{ __html: replaceByParam(descHtml, params) }}
/>
{/* Extra Effects Accordion */}
{Object.keys(extraList).length > 0 && (
<ExtraEffectList extras={extraList} locale={locale} />
)}
+10 -18
View File
@@ -1,6 +1,7 @@
"use client";
import { createContext, PropsWithChildren, useEffect } from "react";
import { createContext, PropsWithChildren, useCallback } from "react";
import useLocaleStore from "@/stores/localeStore";
import { useShallow } from "zustand/react/shallow";
interface ThemeContextType {
theme?: string;
@@ -9,29 +10,20 @@ interface ThemeContextType {
export const ThemeContext = createContext<ThemeContextType>({});
export const ThemeProvider = ({ children }: PropsWithChildren) => {
const { theme, setTheme } = useLocaleStore(
useShallow((state) => ({
theme: state.theme,
setTheme: state.setTheme,
}))
);
const { theme, setTheme } = useLocaleStore()
useEffect(() => {
if (typeof window !== "undefined") {
const storedTheme = localStorage.getItem("theme");
if (storedTheme) setTheme(storedTheme);
}
}, [setTheme]);
const changeTheme = (nextTheme: string | null) => {
const changeTheme = useCallback((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);
}
}
};
}, [theme, setTheme]);
return (
<ThemeContext.Provider value={{ theme, changeTheme }}>
+10 -8
View File
@@ -1,8 +1,10 @@
export * from "./getName"
export * from "./replaceByParam"
export * from "./converterToAvatarStore"
export * from "./calcData"
export * from "./random"
export * from "./json"
export * from "./convertData"
export * from "./connect"
export * from "./getName";
export * from "./replaceByParam";
export * from "./converterToAvatarStore";
export * from "./converterToFreeSRJson";
export * from "./getSkillTree";
export * from "./calcData";
export * from "./random";
export * from "./json";
export * from "./convertData";
export * from "./connect";
+6 -7
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ASGroupDetail, ChangelogItemType, AvatarDetail, FreeSRJson, LightConeDetail, MOCGroupDetail, MonsterDetail, PeakGroupDetail, PFGroupDetail, PSResponse, RelicSetDetail } from "@/types";
import axios from 'axios';
import { psResponseSchema } from "@/zod";
@@ -108,7 +107,7 @@ export async function getChangelog(): Promise<ChangelogItemType[]> {
const res = await axios.get<ChangelogItemType[]>(`/api/data/changelog`);
return res.data;
} catch (error) {
console.error('Failed to fetch monster:', error);
console.error('Failed to fetch changelog:', error);
return [];
}
}
@@ -127,16 +126,16 @@ export async function SendDataToServer(
return "Invalid response schema";
}
return parsed.data;
} catch (error: any) {
return error?.message || "Unknown error";
} catch (error: unknown) {
return (error as Error)?.message || "Unknown error";
}
}
export async function SendDataThroughProxy({ data }: { data: any }) {
export async function SendDataThroughProxy({ data }: { data: Record<string, unknown> | unknown }) {
try {
const response = await axios.post(`/api/proxy`, { ...data })
const response = await axios.post(`/api/proxy`, typeof data === "object" && data !== null ? { ...data } : data)
return response.data;
} catch (error: any) {
} catch (error: unknown) {
return error;
}
}
+4 -1
View File
@@ -4,9 +4,12 @@ import { getASEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import { toast } from 'react-toastify'
import useDetailDataStore from '@/stores/detailDataStore'
import { useShallow } from 'zustand/react/shallow'
export const useFetchASGroupData = () => {
const { setMapAS } = useDetailDataStore()
const { setMapAS } = useDetailDataStore(
useShallow((state) => ({ setMapAS: state.setMapAS }))
)
const query = useQuery({
queryKey: ['ASGroupData'],
+28 -13
View File
@@ -1,4 +1,3 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client"
import { useQuery } from '@tanstack/react-query'
import { getAvatarListApi } from '@/lib/api'
@@ -8,26 +7,42 @@ import useDetailDataStore from '@/stores/detailDataStore'
import useCurrentDataStore from '@/stores/currentDataStore'
import useUserDataStore from '@/stores/userDataStore'
import { converterToAvatarStore } from '@/helper'
import { useShallow } from 'zustand/react/shallow'
export const useFetchAvatarData = () => {
const { setMapAvatar, mapAvatar } = useDetailDataStore()
const { setAvatar, avatars } = useUserDataStore()
const { avatarSelected, setAvatarSelected } = useCurrentDataStore()
const { setMapAvatar, mapAvatar } = useDetailDataStore(
useShallow((state) => ({
setMapAvatar: state.setMapAvatar,
mapAvatar: state.mapAvatar,
}))
)
const { setAvatars, avatars } = useUserDataStore(
useShallow((state) => ({
setAvatars: state.setAvatars,
avatars: state.avatars,
}))
)
const { avatarSelected, setAvatarSelected } = useCurrentDataStore(
useShallow((state) => ({
avatarSelected: state.avatarSelected,
setAvatarSelected: state.setAvatarSelected,
}))
)
const query = useQuery({
queryKey: ['AvatarData'],
queryFn: getAvatarListApi,
staleTime: 1000 * 60 * 5,
})
useEffect(() => {
const listAvatarId = Object.keys(avatars)
const listAvatarNotExist = Object.entries(mapAvatar).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)
}
}, [mapAvatar])
useEffect(() => {
if (!mapAvatar || Object.keys(mapAvatar).length === 0) return
const listAvatarId = Object.keys(avatars)
const listAvatarNotExist = Object.entries(mapAvatar).filter(([avatarId]) => !listAvatarId.includes(avatarId))
if (listAvatarNotExist.length === 0) return
const avatarStore = converterToAvatarStore(Object.fromEntries(listAvatarNotExist))
if (Object.keys(avatarStore).length === 0) return
setAvatars({ ...avatars, ...avatarStore })
}, [mapAvatar, avatars, setAvatars])
useEffect(() => {
if (query.data) {
+11 -2
View File
@@ -5,10 +5,19 @@ import { useEffect } from 'react'
import { toast } from 'react-toastify'
import useModelStore from '@/stores/modelStore'
import useLocaleStore from '@/stores/localeStore'
import { useShallow } from 'zustand/react/shallow'
export const useFetchChangelog = () => {
const { currentVersion, setChangelog, setCurrentVersion } = useLocaleStore()
const { setIsChangelog } = useModelStore()
const { currentVersion, setChangelog, setCurrentVersion } = useLocaleStore(
useShallow((state) => ({
currentVersion: state.currentVersion,
setChangelog: state.setChangelog,
setCurrentVersion: state.setCurrentVersion,
}))
)
const { setIsChangelog } = useModelStore(
useShallow((state) => ({ setIsChangelog: state.setIsChangelog }))
)
const query = useQuery({
queryKey: ['changelog'],
queryFn: getChangelog,
+4 -1
View File
@@ -4,9 +4,12 @@ import { getLightconeListApi } from '@/lib/api';
import { useEffect } from 'react'
import { toast } from 'react-toastify'
import useDetailDataStore from '@/stores/detailDataStore';
import { useShallow } from 'zustand/react/shallow';
export const useFetchLightconeData = () => {
const { setMapLightCone } = useDetailDataStore()
const { setMapLightCone } = useDetailDataStore(
useShallow((state) => ({ setMapLightCone: state.setMapLightCone }))
)
const query = useQuery({
queryKey: ['lightconeData'],
queryFn: getLightconeListApi,
+4 -1
View File
@@ -4,9 +4,12 @@ import { getMOCEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import useDetailDataStore from '@/stores/detailDataStore'
import { toast } from 'react-toastify'
import { useShallow } from 'zustand/react/shallow'
export const useFetchMOCGroupData = () => {
const { setMapMoc } = useDetailDataStore()
const { setMapMoc } = useDetailDataStore(
useShallow((state) => ({ setMapMoc: state.setMapMoc }))
)
const query = useQuery({
queryKey: ['MOCGroupData'],
queryFn: getMOCEventListApi,
+7 -2
View File
@@ -5,10 +5,15 @@ import { toast } from 'react-toastify'
import { getMetadataApi } from '@/lib/api'
import useCurrentDataStore from '@/stores/currentDataStore'
import useDetailDataStore from '@/stores/detailDataStore'
import { useShallow } from 'zustand/react/shallow'
export const useFetchConfigData = () => {
const { setMetaData } = useDetailDataStore()
const { setSettingData } = useCurrentDataStore()
const { setMetaData } = useDetailDataStore(
useShallow((state) => ({ setMetaData: state.setMetaData }))
)
const { setSettingData } = useCurrentDataStore(
useShallow((state) => ({ setSettingData: state.setSettingData }))
)
const query = useQuery({
queryKey: ['initialConfigData'],
+4 -1
View File
@@ -4,9 +4,12 @@ import { getMonsterListApi } from '@/lib/api'
import { useEffect } from 'react'
import useDetailDataStore from '@/stores/detailDataStore'
import { toast } from 'react-toastify'
import { useShallow } from 'zustand/react/shallow'
export const useFetchMonsterData = () => {
const { setMapMonster } = useDetailDataStore()
const { setMapMonster } = useDetailDataStore(
useShallow((state) => ({ setMapMonster: state.setMapMonster }))
)
const query = useQuery({
queryKey: ['MonsterData'],
queryFn: getMonsterListApi,
+4 -1
View File
@@ -4,9 +4,12 @@ import { getPeakEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import useDetailDataStore from '@/stores/detailDataStore'
import { toast } from 'react-toastify'
import { useShallow } from 'zustand/react/shallow'
export const useFetchPeakGroupData = () => {
const { setMapPeak } = useDetailDataStore()
const { setMapPeak } = useDetailDataStore(
useShallow((state) => ({ setMapPeak: state.setMapPeak }))
)
const query = useQuery({
queryKey: ['PeakGroupData'],
queryFn: getPeakEventListApi,
+4 -1
View File
@@ -4,9 +4,12 @@ import { getPFEventListApi } from '@/lib/api'
import { useEffect } from 'react'
import useDetailDataStore from '@/stores/detailDataStore'
import { toast } from 'react-toastify'
import { useShallow } from 'zustand/react/shallow'
export const useFetchPFGroupData = () => {
const { setMapPF } = useDetailDataStore()
const { setMapPF } = useDetailDataStore(
useShallow((state) => ({ setMapPF: state.setMapPF }))
)
const query = useQuery({
queryKey: ['PFGroupData'],
queryFn: getPFEventListApi,
+4 -1
View File
@@ -4,9 +4,12 @@ import { getRelicSetListApi } from '@/lib/api'
import { useEffect } from 'react'
import useDetailDataStore from '@/stores/detailDataStore'
import { toast } from 'react-toastify'
import { useShallow } from 'zustand/react/shallow'
export const useFetchRelicSetData = () => {
const { setMapRelicSet } = useDetailDataStore()
const { setMapRelicSet } = useDetailDataStore(
useShallow((state) => ({ setMapRelicSet: state.setMapRelicSet }))
)
const query = useQuery({
queryKey: ['RelicSetData'],
queryFn: getRelicSetListApi,
-2
View File
@@ -21,8 +21,6 @@ const useCopyProfileStore = create<CopyProfileState>((set) => ({
listElement: {},
listPath: {},
listRank: { "4": false, "5": false },
listCopyAvatar: [],
listRawCopyAvatar: [],
setListElement: (newListElement: Record<string, boolean>) => set({ listElement: newListElement }),
setListPath: (newListPath: Record<string, boolean>) => set({ listPath: newListPath }),
+8 -6
View File
@@ -19,7 +19,7 @@ interface CurrentDataState {
setMapAvatarPathActive: (mapAvatarPathActive: Record<string, boolean>) => void;
setMapLightconePathActive: (mapLightconePathActive: Record<string, boolean>) => void;
setMapLightconeRankActive: (mapLightconeRankActive: Record<string, boolean>) => void;
setResetDataLightcone: (avtarSeleted: AvatarDetail| null, baseType: Record<string, BaseTypeData>) => void;
setResetDataLightcone: (avatarSelected: AvatarDetail | null, baseType: Record<string, BaseTypeData>) => void;
}
const useCurrentDataStore = create<CurrentDataState>((set) => ({
@@ -44,14 +44,16 @@ const useCurrentDataStore = create<CurrentDataState>((set) => ({
setMapAvatarPathActive: (mapAvatarPathActive: Record<string, boolean>) => set({ mapAvatarPathActive }),
setMapLightconePathActive: (mapLightconePathActive: Record<string, boolean>) => set({ mapLightconePathActive }),
setMapLightconeRankActive: (mapLightconeRankActive: Record<string, boolean>) => set({ mapLightconeRankActive }),
setResetDataLightcone: (avtarSeleted: AvatarDetail| null, baseType: Record<string, BaseTypeData>) => {
setResetDataLightcone: (avatarSelected: AvatarDetail | null, baseType: Record<string, BaseTypeData>) => {
const newListPath: Record<string, boolean> = Object.fromEntries(Object.keys(baseType).map(key => [key, false]))
const newListRank: Record<string, boolean> = {"3": false, "4": false, "5": false }
if (avtarSeleted) {
newListPath[avtarSeleted.BaseType] = true
if (avatarSelected) {
newListPath[avatarSelected.BaseType] = true
}
set({ mapLightconeRankActive: newListRank })
set({ mapLightconePathActive: newListPath })
set({
mapLightconeRankActive: newListRank,
mapLightconePathActive: newListPath,
})
}
}))
-4
View File
@@ -4,7 +4,6 @@ import { create } from 'zustand'
interface ModelState {
isOpenLightcone: boolean;
isOpenRelic: boolean;
isOpenAvatar: boolean;
isOpenCreateProfile: boolean;
isOpenImport: boolean;
isOpenCopy: boolean;
@@ -21,7 +20,6 @@ interface ModelState {
setIsOpenMonster: (newIsOpenMonster: boolean) => void;
setIsOpenLightcone: (newIsOpenLightcone: boolean) => void;
setIsOpenRelic: (newIsOpenRelic: boolean) => void;
setIsOpenAvatar: (newIsOpenAvatar: boolean) => void;
setIsOpenCreateProfile: (newIsOpenCreateProfile: boolean) => void;
setIsOpenImport: (newIsOpenImport: boolean) => void;
setIsOpenCopy: (newIsOpenCopy: boolean) => void;
@@ -31,7 +29,6 @@ interface ModelState {
const useModelStore = create<ModelState>((set) => ({
isOpenLightcone: false,
isOpenRelic: false,
isOpenAvatar: false,
isOpenCreateProfile: false,
isOpenImport: false,
isOpenCopy: false,
@@ -48,7 +45,6 @@ const useModelStore = create<ModelState>((set) => ({
setIsOpenMonster: (newIsOpenMonster: boolean) => set({ isOpenMonster: newIsOpenMonster }),
setIsOpenLightcone: (newIsOpenLightcone: boolean) => set({ isOpenLightcone: newIsOpenLightcone }),
setIsOpenRelic: (newIsOpenRelic: boolean) => set({ isOpenRelic: newIsOpenRelic }),
setIsOpenAvatar: (newIsOpenAvatar: boolean) => set({ isOpenAvatar: newIsOpenAvatar }),
setIsOpenCreateProfile: (newIsOpenCreateProfile: boolean) => set({ isOpenCreateProfile: newIsOpenCreateProfile }),
setIsOpenImport: (newIsOpenImport: boolean) => set({ isOpenImport: newIsOpenImport }),
setIsOpenCopy: (newIsOpenCopy: boolean) => set({ isOpenCopy: newIsOpenCopy }),
+25 -32
View File
@@ -129,7 +129,7 @@ const useRelicMakerStore = create<RelicMakerState>((set) => ({
listSelectedSubStats: newSubAffixes.map(item => ({ ...item }))
})),
resetHistory: (index: number | null) => set((state) => {
if (index !== null) {
if (index === null) {
return {
preSelectedSubStats: {
"0": [
@@ -166,47 +166,40 @@ const useRelicMakerStore = create<RelicMakerState>((set) => ({
],
}
}
} else if (index !== null) {
const newPreSelectedSubStats = { ...state.preSelectedSubStats };
newPreSelectedSubStats[index] = [
{
property: "",
affixId: "",
rollCount: 0,
stepCount: 0
},
]
return {
preSelectedSubStats: newPreSelectedSubStats
}
}
return {}
const newPreSelectedSubStats = { ...state.preSelectedSubStats };
newPreSelectedSubStats[index.toString()] = [
{
property: "",
affixId: "",
rollCount: 0,
stepCount: 0
},
]
return {
preSelectedSubStats: newPreSelectedSubStats
}
}),
popHistory: (index: number) => set((state) => {
const key = index.toString();
const currentList = state.preSelectedSubStats[key];
if (!currentList || currentList.length <= 1) return {};
const newPreSelectedSubStats = structuredClone(state.preSelectedSubStats);
const copied = state.preSelectedSubStats[index].map(item => ({ ...item }));
const copied = currentList.map(item => ({ ...item }));
copied.pop();
newPreSelectedSubStats[index] = copied;
newPreSelectedSubStats[key] = copied;
return { preSelectedSubStats: newPreSelectedSubStats };
}),
addHistory: (index, affix) => set((state) => {
const key = index.toString();
const newPreSelectedSubStats = structuredClone(state.preSelectedSubStats);
const currentList = state.preSelectedSubStats[index];
const copied = currentList ? currentList.map(item => ({ ...item })) : [
{ property: "", affixId: "", rollCount: 0, stepCount: 0 },
{ property: "", affixId: "", rollCount: 0, stepCount: 0 },
{ property: "", affixId: "", rollCount: 0, stepCount: 0 },
{ property: "", affixId: "", rollCount: 0, stepCount: 0 },
const currentList = state.preSelectedSubStats[key] || [
{ property: "", affixId: "", rollCount: 0, stepCount: 0 }
];
if (copied) {
const newAffix = structuredClone(affix);
copied.push(newAffix);
newPreSelectedSubStats[index] = copied;
return { preSelectedSubStats: newPreSelectedSubStats };
}
return {}
const copied = currentList.map(item => ({ ...item }));
copied.push(structuredClone(affix));
newPreSelectedSubStats[key] = copied;
return { preSelectedSubStats: newPreSelectedSubStats };
}),
}));
+3 -3
View File
@@ -42,7 +42,7 @@ const useUserDataStore = create<UserDataState>()(
moc_config: {
event_id: 0,
challenge_id: 0,
floor_side: "Upper",
floor_side: "firstNode",
use_turbulence_buff: true,
use_cycle_count: true,
blessings: [],
@@ -54,7 +54,7 @@ const useUserDataStore = create<UserDataState>()(
event_id: 0,
challenge_id: 0,
buff_id: 0,
floor_side: "Upper",
floor_side: "firstNode",
blessings: [],
cycle_count: 0,
stage_id: 0,
@@ -64,7 +64,7 @@ const useUserDataStore = create<UserDataState>()(
event_id: 0,
challenge_id: 0,
buff_id: 0,
floor_side: "Upper",
floor_side: "firstNode",
blessings: [],
cycle_count: 0,
stage_id: 0,
+2 -1
View File
@@ -1,5 +1,5 @@
import { ExtraEffect } from "./avatarDetail";
import { InfiniteWave, MazeBuff } from "./pfDetail";
import { InfiniteWave, Invasion, MazeBuff } from "./pfDetail";
export interface ASGroupDetail {
ID: number;
@@ -64,6 +64,7 @@ export interface ASEvent {
Level: number;
Release: boolean;
MonsterList: number[][];
Invasion: Invasion | null;
Infinite: InfiniteWave[] | null;
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.css';
declare module '*.scss';
declare module '*.module.css';
declare module '*.module.scss';
+3 -1
View File
@@ -1,4 +1,4 @@
import { InfiniteWave, MazeBuff } from "./pfDetail";
import { InfiniteWave, Invasion, MazeBuff } from "./pfDetail";
export interface MOCGroupDetail {
ID: number;
@@ -40,6 +40,7 @@ export interface MoCTarget {
Param: number[];
}
export interface MoCEvent {
ID: number;
Name: Record<string, string>;
@@ -48,5 +49,6 @@ export interface MoCEvent {
Level: number;
Release: boolean;
MonsterList: number[][];
Invasion: Invasion | null;
Infinite: InfiniteWave[] | null;
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { BattleTarget, InfiniteWave, MazeBuff } from "./pfDetail";
import { BattleTarget, InfiniteWave, Invasion, MazeBuff } from "./pfDetail";
export interface PeakGroupDetail {
ID: number;
@@ -37,5 +37,6 @@ export interface PeakEvent {
Level: number;
Release: boolean;
MonsterList: number[][];
Invasion: Invasion | null;
Infinite: InfiniteWave[] | null;
}
+13 -7
View File
@@ -18,6 +18,12 @@ export interface MazeBuff {
Desc: Record<string, string>;
}
export interface Invasion {
ID: number;
MazeBuff: MazeBuff;
Desc: Record<string, string>;
}
export interface PFTierceLevel {
ID: number;
PreChallenge: number;
@@ -55,7 +61,12 @@ export interface BattleTarget {
Param: number[];
Name: Record<string, string>;
}
export interface InfiniteWave {
ID: number;
MaxMonsterCount: number;
MonsterList: number[];
EliteGroup: number;
}
export interface PFEvent {
ID: number;
Name: Record<string, string>;
@@ -64,12 +75,7 @@ export interface PFEvent {
Level: number;
Release: boolean;
MonsterList: number[][];
Invasion: Invasion | null;
Infinite?: InfiniteWave[] | null;
}
export interface InfiniteWave {
ID: number;
MaxMonsterCount: number;
MonsterList: number[];
EliteGroup: number;
}
+7 -7
View File
@@ -25,11 +25,11 @@ export interface LightconeJson {
export interface AvatarData {
rank: number,
skills: Record<string, number>
skills_by_anchor_type?: Record<string,number>
skills_by_anchor_type?: Record<string, number> | null;
}
export interface AvatarJson {
owner_uid?: number;
owner_uid?: number | null;
avatar_id: number;
data: AvatarData;
level: number;
@@ -37,12 +37,12 @@ export interface AvatarJson {
techniques: number[];
sp_value: number;
sp_max: number;
enhanced_id?: number;
enhanced_id?: number | null;
}
export interface MonsterJson {
monster_id: number;
level: number;
amount: number;
amount?: number | null;
}
export interface DynamicKeyJson {
@@ -53,7 +53,7 @@ export interface DynamicKeyJson {
export interface BattleBuffJson {
level: number;
id: number;
dynamic_key?: DynamicKeyJson;
dynamic_key?: DynamicKeyJson | null;
}
export interface BattleConfigJson {
@@ -71,12 +71,12 @@ type LoadoutJson = {
relic_list: string[]
}
export interface FreeSRJson {
key?: string;
key?: string | null;
lightcones: LightconeJson[];
relics: RelicJson[];
avatars: Record<string, AvatarJson>;
battle_config: BattleConfigJson;
loadout?: LoadoutJson[];
loadout?: LoadoutJson[] | null;
}
+8 -8
View File
@@ -29,11 +29,11 @@ export const lightconeJsonSchema = z.object({
export const avatarDataSchema = z.object({
rank: z.number(),
skills: z.record(z.string(), z.number()),
skills_by_anchor_type: z.record(z.string(), z.number()).optional()
skills_by_anchor_type: z.record(z.string(), z.number()).nullish()
});
export const avatarJsonSchema = z.object({
owner_uid: z.number().optional(),
owner_uid: z.number().nullish(),
avatar_id: z.number(),
data: avatarDataSchema,
level: z.number(),
@@ -41,13 +41,13 @@ export const avatarJsonSchema = z.object({
techniques: z.array(z.number()),
sp_value: z.number(),
sp_max: z.number(),
enhanced_id: z.number().optional()
enhanced_id: z.number().nullish()
});
export const monsterJsonSchema = z.object({
monster_id: z.number(),
level: z.number(),
amount: z.number()
amount: z.number().nullish()
});
export const dynamicKeyJsonSchema = z.object({
@@ -58,7 +58,7 @@ export const dynamicKeyJsonSchema = z.object({
export const battleBuffJsonSchema = z.object({
level: z.number(),
id: z.number(),
dynamic_key: dynamicKeyJsonSchema.optional()
dynamic_key: dynamicKeyJsonSchema.nullish()
});
export const battleConfigJsonSchema = z.object({
@@ -78,12 +78,12 @@ const loadoutJsonSchema = z.object({
});
export const freeSrJsonSchema = z.object({
key: z.string().optional(),
key: z.string().nullish(),
lightcones: z.array(lightconeJsonSchema),
relics: z.array(relicJsonSchema),
avatars: z.record(z.string(), avatarJsonSchema),
battle_config: battleConfigJsonSchema,
loadout: z.array(loadoutJsonSchema).optional()
loadout: z.array(loadoutJsonSchema).nullish()
});
const extraDataSchema = z.any();
@@ -91,5 +91,5 @@ const extraDataSchema = z.any();
export const psResponseSchema = z.object({
status: z.number(),
message: z.string(),
extra_data: extraDataSchema.optional()
extra_data: extraDataSchema.nullish()
});