This commit is contained in:
2025-04-23 20:24:12 +07:00
parent f933b5aad5
commit ef52d632bd
63 changed files with 3911 additions and 138 deletions
+260
View File
@@ -0,0 +1,260 @@
"use client";
import useAvatarDataStore from "@/stores/avatarDataStore";
import useBattleDataStore from "@/stores/battleDataStore";
import useLocaleStore from "@/stores/localeStore";
import { AvatarType } from "@/types";
import { TurnBattleInfo } from "@/types/mics";
import { useEffect, useState, useRef } from "react";
import { useTranslations } from "next-intl";
import { motion } from "framer-motion";
import { getNameChar } from "@/helper";
export default function ActionBar() {
const [selectTurn, setSelectTurn] = useState<TurnBattleInfo | null>(null);
const [selectAvatar, setSelectAvatar] = useState<AvatarType | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const { turnHistory } = useBattleDataStore();
const { listAvatar } = useAvatarDataStore();
const { locale } = useLocaleStore();
const transI18n = useTranslations("DataAnalysisPage");
const turnListRef = useRef<HTMLDivElement>(null);
const parallelogramStyle: React.CSSProperties = {
transform: 'skew(-9deg)',
overflow: 'hidden',
};
const contentStyle: React.CSSProperties = {
transform: 'skew(9deg)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
};
const handleShow = (modalId: string, avatar: AvatarType, turn: TurnBattleInfo) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
setSelectAvatar(avatar);
setSelectTurn(turn);
setIsModalOpen(true);
modal.showModal();
}
};
// Close modal handler
const handleCloseModal = (modalId: string) => {
setIsModalOpen(false);
setSelectAvatar(null);
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.close();
}
};
// Handle ESC key to close modal
useEffect(() => {
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isModalOpen) {
handleCloseModal("action_detail_modal");
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
}, [isModalOpen]);
// Scroll to the bottom when new turns are added
useEffect(() => {
if (turnListRef.current && turnHistory.length > 0) {
turnListRef.current.scrollTop = turnListRef.current.scrollHeight;
}
}, [turnHistory.length]);
return (
<div className="p-4 rounded-lg shadow-lg w-full h-full min-h-[74vh]">
<motion.h2
className="text-center text-xl lg:text-2xl mb-2 font-bold text-transparent bg-clip-text bg-gradient-to-r from-pink-500 via-purple-500 to-cyan-500"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{transI18n("actionBar")}
</motion.h2>
<div
ref={turnListRef}
className="flex px-2 w-full max-h-[90vh] pt-2 border-t-2 border-accent overflow-y-auto custom-scrollbar overflow-x-hidden"
>
<style jsx>{`
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--p)) hsl(var(--b3));
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: hsl(var(--b3));
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--p));
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--pf));
}
.custom-scrollbar::-webkit-scrollbar-button {
display: none;
height: 0;
width: 0;
}
`}</style>
<div className="w-full h-fit grid grid-cols-1 gap-1 ">
{turnHistory.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-base-content opacity-50">{transI18n("noTurns")}</p>
</div>
) : (
turnHistory.map((turn, index) => {
const data = listAvatar.find(it => it.id === turn.avatarId.toString());
if (!data) return null;
const text = getNameChar(locale, data);
return (
<div key={index}>
<div
onClick={() => handleShow("action_detail_modal", data, turn)}
style={parallelogramStyle}
className="flex border bg-base-100 w-full hover:bg-base-200 transition-colors duration-200 border-cyan-400 border-l-4 cursor-pointer"
>
<div
style={contentStyle}
className="flex flex-col items-center justify-center py-2 px-3"
>
<div className="avatar">
<div className="w-12 h-12 rounded-full border-2 flex items-center justify-center bg-base-300 border-cyan-400 border-l-4">
<img
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${data.id}.webp`}
alt={text}
className="w-8 h-8 object-contain"
/>
</div>
</div>
<div className="text-base-content text-sm mt-1 font-medium">{text}</div>
</div>
<div className="flex flex-col justify-center gap-2 px-3 py-2">
<div className="text-primary text-xs">
{`${transI18n("useSkill")}: ${turn.skillType}`}
</div>
<div className="text-primary text-xs">
{`${transI18n("totalDamage")}: ${turn.totalDamage.toFixed(2)}`}
</div>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* Character Detail Modal */}
<dialog id="action_detail_modal" className="modal modal-bottom sm:modal-middle backdrop-blur-sm">
<div className="modal-box w-11/12 max-w-7xl bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="sticky top-0 z-10">
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
onClick={() => handleCloseModal("action_detail_modal")}
>
✕
</motion.button>
</div>
<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-gradient-to-r from-pink-400 to-cyan-400">
{transI18n("turnDetail").toUpperCase()}
</h3>
</div>
{selectAvatar && selectTurn && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mt-4 w-full custom-scrollbar"
>
<div className="bg-base-200 rounded-lg p-4 shadow-md mb-4">
<h4 className="text-lg font-semibold mb-2 text-pink-500">{transI18n("characterInformation")}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center">
<div className="flex flex-col space-y-2">
<p className="flex items-center gap-2">
<span className="font-medium text-base-content/70">{transI18n("id")}:</span>
<span className="font-bold">{selectAvatar.id}</span>
</p>
<p className="flex items-center gap-2">
<span className="font-medium text-base-content/70">{transI18n("character")}:</span>
<span className="font-bold">{getNameChar(locale, selectAvatar)}</span>
</p>
</div>
<div className="flex justify-center items-center">
<img
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${selectAvatar.id}.webp`}
alt={getNameChar(locale, selectAvatar)}
className="h-20 w-20 object-cover rounded-full border-2 border-purple-500 shadow-lg shadow-purple-500/20"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="bg-base-200 rounded-lg p-4 shadow-md">
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("skillType")}</h4>
<p className="mt-2">{transI18n(selectTurn?.skillType.toLowerCase())}</p>
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md">
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("skillName")}</h4>
<p className="mt-2">{selectTurn?.skillName}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="bg-base-200 rounded-lg p-4 shadow-md">
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("actionValue")}</h4>
<p className="mt-2">{selectTurn?.actionValue.toFixed(2)}</p>
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md">
<h4 className="text-lg font-semibold mb-2 text-purple-500 border-b border-purple-300/30 pb-1">{transI18n("totalDamage")}</h4>
<p className="mt-2 font-bold text-lg">{selectTurn?.totalDamage.toFixed(2)}</p>
</div>
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md mb-4">
<h4 className="text-lg font-semibold mb-2 text-cyan-500 border-b border-cyan-300/30 pb-1">{transI18n("damageDetails")}</h4>
{selectTurn?.damageDetail && selectTurn.damageDetail.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 mt-3">
{selectTurn.damageDetail.map((detail, idx) => (
<p key={idx} className="py-1 px-2 rounded bg-base-300 text-sm">{detail.toFixed(2)}</p>
))}
</div>
) : (
<p className="mt-2 italic opacity-70">{transI18n("noDamageDetail")}</p>
)}
</div>
</motion.div>
)}
</div>
</dialog>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { getNameChar } from '@/helper';
import useLocaleStore from '@/stores/localeStore';
import { AvatarType } from '@/types';
interface CharacterCardProps {
data: AvatarType
}
export function parseRuby(text: string): string {
return text.replace(/\{RUBY_B#(.*?)\}(.*?)\{RUBY_E#\}/g, (_, furigana, kanji) => {
return `<ruby>${kanji}<rt>${furigana}</rt></ruby>`;
});
}
export default function CharacterCard({ data }: CharacterCardProps) {
const { locale } = useLocaleStore();
const text = getNameChar(locale, data)
return (
<li className="z-10 flex flex-col w-28 items-center p-1 rounded-md shadow-lg bg-gradient-to-b from-customStart to-customEnd transform transition-transform duration-300 hover:scale-105 m-1">
<div
className={`w-[80px] rounded-md p-[2px] bg-gradient-to-br ${
data.rank === "CombatPowerAvatarRarityType5"
? "from-yellow-400 via-yellow-300 to-yellow-500"
: "from-purple-300 via-purple-200 to-purple-400"
}`}
>
<div className="relative w-full h-full">
<img
loading="lazy"
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${data.id}.webp`}
className="w-full h-full rounded-md object-cover"
alt="ALT"
/>
<img
loading='lazy'
src={`https://api.hakush.in/hsr/UI/element/${data.damageType.toLowerCase()}.webp`}
className="absolute top-0 left-0 w-6 h-6"
alt={data.damageType.toLowerCase()}
/>
<img
loading='lazy'
src={`https://api.hakush.in/hsr/UI/pathicon/${data.baseType.toLowerCase()}.webp`}
className="absolute top-0 right-0 w-6 h-6"
alt={data.baseType.toLowerCase()}
/>
</div>
</div>
{locale === "jp" ? (
<div
className="mt-2 text-center text-base font-normal leading-tight"
dangerouslySetInnerHTML={{ __html: parseRuby(text) }}
/>
) : (
<div className="mt-2 text-center text-base font-normal leading-tight">
{text}
</div>
)}
</li>
);
}
+65
View File
@@ -0,0 +1,65 @@
'use client';
import {
Chart as ChartJS,
LineElement,
CategoryScale,
LinearScale,
PointElement,
Tooltip,
Legend,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { useDamageLinesForAll } from '@/hooks/useDamageLinesForAll';
import useAvatarDataStore from '@/stores/avatarDataStore';
import useLocaleStore from '@/stores/localeStore';
import { getNameChar } from '@/helper';
ChartJS.register(LineElement, CategoryScale, LinearScale, PointElement, Tooltip, Legend);
const colors = ['#f87171', '#34d399', '#60a5fa', '#facc15', '#a78bfa', '#fb923c'];
export default function MultiCharLineChart({ mode = 0 }: { mode?: 0 | 1 }) {
const dataByAvatar = useDamageLinesForAll(mode);
const avatarIds = Object.keys(dataByAvatar).map(Number);
const { listAvatar } = useAvatarDataStore()
const { locale } = useLocaleStore();
const data = {
datasets: avatarIds.map((id, idx) => ({
label: getNameChar(locale, listAvatar.find(it => it.id == id.toString())),
data: dataByAvatar[id].map(({ x, y }: { x: number; y: number }) => {
return {
x: x.toFixed(2),
y: y.toFixed(2)
}
}),
borderColor: colors[idx % colors.length],
backgroundColor: colors[idx % colors.length],
fill: false,
tension: 0.3,
})),
};
const options = {
responsive: true,
scales: {
x: {
type: 'linear' as const,
position: 'bottom' as "bottom" | "center" | "left" | "top" | "right",
ticks: {
stepSize: 1,
},
},
y: {
beginAtZero: true,
},
},
plugins: {
datalabels: {
display: false,
},
},
};
return <Line data={data} options={options} />;
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { Line } from "react-chartjs-2";
import { ChartOptions } from "chart.js";
import { useDamageLineForOne } from "@/hooks/useDamageLineForOne";
import {
Chart as ChartJS,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js";
import { useTranslations } from "next-intl";
ChartJS.register(
LineElement,
PointElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
Filler
);
ChartJS.defaults.set('plugins.datalabels', {
color: '#FE777B'
});
export function DamageLineForOne({ avatarId, mode = 0 }: { avatarId: number; mode?: 0 | 1 }) {
const dataRaw = useDamageLineForOne(avatarId, mode);
const transI18n = useTranslations("DataAnalysisPage");
const data = {
labels: dataRaw.map(d => Number(d.x.toFixed(2))),
datasets: [
{
label: mode === 1 ? transI18n("cumulativeDamage") : transI18n("damageByActionValue"),
data: dataRaw.map(d => Number(d.y.toFixed(2))),
borderColor: "rgba(75,192,192,1)",
backgroundColor: "rgba(75,192,192,0.2)",
tension: 0.3,
fill: true,
},
],
};
const options: ChartOptions<"line"> = {
responsive: true,
plugins: {
legend: { display: true },
datalabels: {
display: false,
},
},
scales: {
x: { title: { display: true, text: transI18n("actionValue") } },
y: { title: { display: true, text: transI18n("damage") }, beginAtZero: true },
},
};
return <Line data={data} options={options} />;
}
@@ -0,0 +1,80 @@
"use client";
import { getNameChar } from '@/helper';
import useAvatarDataStore from '@/stores/avatarDataStore';
import useBattleDataStore from '@/stores/battleDataStore';
import useLocaleStore from '@/stores/localeStore';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
} from 'chart.js';
import { useTranslations } from 'next-intl';
import { Bar } from 'react-chartjs-2';
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend);
const colorPalette = [
'rgba(255, 99, 132, 0.6)',
'rgba(54, 162, 235, 0.6)',
'rgba(255, 206, 86, 0.6)',
'rgba(75, 192, 192, 0.6)',
'rgba(153, 102, 255, 0.6)',
'rgba(255, 159, 64, 0.6)',
'rgba(199, 199, 199, 0.6)',
];
const borderPalette = colorPalette.map((color) => color.replace('0.6', '1'));
export default function DamagePerAvatarForAll() {
const transI18n = useTranslations("DataAnalysisPage");
const { lineup, turnHistory } = useBattleDataStore()
const { listAvatar } = useAvatarDataStore()
const { locale } = useLocaleStore();
const damageByAvatar = lineup.map((avatar) => {
const turns = turnHistory.filter((turn) => turn.avatarId === avatar.avatarId);
const totalDmg = turns.reduce((sum, turn) => sum + turn.totalDamage, 0);
const char = listAvatar.find(it => it.id === avatar.avatarId.toString())
if (!char) return
return {
avatarId: avatar.avatarId,
damage: totalDmg,
avatarName: getNameChar(locale, char)
};
});
const data = {
labels: damageByAvatar.map((item) => item?.avatarName),
datasets: [
{
label: transI18n("totalDamage"),
data: damageByAvatar.map((item) => item?.damage),
backgroundColor: damageByAvatar.map((_, idx) => colorPalette[idx % colorPalette.length]),
borderColor: damageByAvatar.map((_, idx) => borderPalette[idx % borderPalette.length]),
borderWidth: 1,
},
],
};
const options = {
responsive: true,
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
},
},
},
plugins: {
datalabels: {
display: false,
},
},
};
return <Bar data={data} options={options} />;
}
@@ -0,0 +1,44 @@
"use client";
import { Bar } from "react-chartjs-2";
import { ChartOptions } from "chart.js";
import { useDamagePerCycleForAll } from "@/hooks/useDamagePerCycle";
import { Chart as ChartJS, BarElement, ArcElement, RadialLinearScale } from "chart.js";
import { useTranslations } from "next-intl";
ChartJS.register(BarElement, ArcElement, RadialLinearScale);
export default function DamagePerCycleForAll({
mode,
}: {
mode: 0 | 1 | 2;
}) {
const dataRaw = useDamagePerCycleForAll(mode);
const transI18n = useTranslations("DataAnalysisPage");
const data = {
labels: dataRaw.map(d => d.x),
datasets: [
{
label: mode === 0 ? `${transI18n("damagerPerCycle")} (100av)` : mode === 1 ? `${transI18n("damagerPerCycle")} (150av | 100v)` : `${transI18n("damagerPerCycle")} (150av | 150av | 100v)`,
data: dataRaw.map(d => d.y),
backgroundColor: "rgba(255,99,132,0.6)",
},
],
};
const options: ChartOptions<"bar"> = {
responsive: true,
plugins: {
legend: { display: true },
datalabels: {
display: false,
},
},
scales: {
x: { title: { display: true, text: transI18n("cycleCount") } },
y: { title: { display: true, text: transI18n("totalDamage") }, beginAtZero: true },
},
};
return <Bar data={data} options={options} />;
}
@@ -0,0 +1,46 @@
"use client";
import { Bar } from "react-chartjs-2";
import { ChartOptions } from "chart.js";
import { useDamagePerCycleForOne } from "@/hooks/useDamagePerCycle";
import { Chart as ChartJS, BarElement, ArcElement, RadialLinearScale } from "chart.js";
import { useTranslations } from "next-intl";
ChartJS.register(BarElement, ArcElement, RadialLinearScale);
export function DamagePerCycleForOne({
avatarId,
mode,
}: {
avatarId: number;
mode: 0 | 1 | 2;
}) {
const dataRaw = useDamagePerCycleForOne(avatarId, mode);
const transI18n = useTranslations("DataAnalysisPage");
const data = {
labels: dataRaw.map(d => d.x),
datasets: [
{
label: mode === 0 ? `${transI18n("damagerPerCycle")} (100av)` : mode === 1 ? `${transI18n("damagerPerCycle")} (150av | 100v)` : `${transI18n("damagerPerCycle")} (150av | 150av | 100v)`,
data: dataRaw.map(d => d.y),
backgroundColor: "rgba(255,99,132,0.6)",
},
],
};
const options: ChartOptions<"bar"> = {
responsive: true,
plugins: {
legend: { display: true },
datalabels: {
display: false,
},
},
scales: {
x: { title: { display: true, text: transI18n("cycleCount") } },
y: { title: { display: true, text: transI18n("totalDamage") }, beginAtZero: true },
},
};
return <Bar data={data} options={options} />;
}
@@ -0,0 +1,51 @@
'use client';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend
} from 'chart.js';
import { Pie } from 'react-chartjs-2';
import { useDamagePercentPerAvatar } from '@/hooks/useDamagePercentPerAvatar';
import useAvatarDataStore from '@/stores/avatarDataStore';
import useLocaleStore from '@/stores/localeStore';
import { getNameChar } from '@/helper';
import { useTranslations } from 'next-intl';
ChartJS.register(ArcElement, Tooltip, Legend);
const colors = ['#f87171', '#34d399', '#60a5fa', '#facc15', '#a78bfa', '#fb923c', '#f472b6'];
export default function DamagePercentChartForAll() {
const data = useDamagePercentPerAvatar();
const { listAvatar } = useAvatarDataStore()
const { locale } = useLocaleStore();
const transI18n = useTranslations("DataAnalysisPage");
const chartData = {
labels: data.map(d => getNameChar(locale, listAvatar.find(it => it.id == d.avatarId.toString()))),
datasets: [
{
label: '% ' + transI18n("damage"),
data: data.map(d => d.percent.toFixed(2)),
backgroundColor: colors,
borderWidth: 1,
},
],
};
const options = {
responsive: true,
plugins: {
tooltip: {
callbacks: {
label: (ctx: import('chart.js').TooltipItem<'pie'>) => `${ctx.label}: ${ctx.raw}%`,
},
},
datalabels: {
display: false,
},
},
};
return <Pie data={chartData} options={options} />;
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { getNameChar } from '@/helper';
import { useSkillDamageByAvatar } from '@/hooks/useSkillDamageStats';
import useAvatarDataStore from '@/stores/avatarDataStore';
import useLocaleStore from '@/stores/localeStore';
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
} from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { useTranslations } from 'next-intl';
import { Bar } from 'react-chartjs-2';
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, ChartDataLabels);
export default function SkillBarChart({ avatarId }: { avatarId: number }) {
const { labels, damageValues } = useSkillDamageByAvatar(avatarId);
const transI18n = useTranslations("DataAnalysisPage");
const colors = ['#f87171', '#facc15', '#34d399', '#60a5fa', '#a78bfa', '#fb923c'];
const { listAvatar } = useAvatarDataStore()
const { locale } = useLocaleStore();
const data = {
labels: labels.map(it => transI18n(it.toLowerCase())),
datasets: [
{
label: getNameChar(locale, listAvatar.find(it => it.id === avatarId.toString())),
data: damageValues,
backgroundColor: colors.slice(0, labels.length),
borderColor: '#111',
borderWidth: 1,
},
],
};
const options = {
responsive: true,
scales: {
y: {
beginAtZero: true,
ticks: { precision: 0 },
},
},
plugins: {
legend: {
display: false,
},
datalabels: {
display: false,
},
},
};
return <Bar data={data} options={options} />;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { useSkillDamageByAvatar } from '@/hooks/useSkillDamageStats';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
} from 'chart.js';
import { Pie } from 'react-chartjs-2';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { useTranslations } from 'next-intl';
ChartJS.register(ArcElement, Tooltip, Legend, ChartDataLabels);
export default function SkillPieChart({ avatarId }: { avatarId: number }) {
const { labels, damageValues } = useSkillDamageByAvatar(avatarId);
const transI18n = useTranslations("DataAnalysisPage");
const total = damageValues.reduce((sum, val) => sum + val, 0);
const labelsWithPercent = labels.map((label, index) => {
const value = damageValues[index];
const percent = total ? ((value / total) * 100).toFixed(1) : '0.0';
return `${transI18n(label.toLowerCase())} (${percent}%)`;
});
const data = {
labels: labelsWithPercent,
datasets: [
{
data: damageValues,
backgroundColor: [
'#f87171', // red
'#facc15', // yellow
'#34d399', // green
'#60a5fa', // blue
'#a78bfa', // purple
'#fb923c', // orange
],
borderColor: '#fff',
borderWidth: 2,
},
],
};
return (
<Pie
data={data}
options={{
responsive: true,
plugins: {
legend: { position: 'right' },
datalabels: {
display: false,
},
},
}}
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
export default function Footer() {
return(
<footer className="footer footer-horizontal footer-center bg-base-200 text-base-content rounded p-10">
<aside>
<p>Copyright © {new Date().getFullYear()} - Kain (Powered by Nextjs & DaisyUi)</p>
</aside>
</footer>
)
}
+459
View File
@@ -0,0 +1,459 @@
"use client"
import { exportBattleData, importBattleData } from "@/helper";
import { useChangeTheme } from "@/hooks/useChangeTheme";
import { checkConnectTcpApi } from "@/lib/api";
import { connectSocket, disconnectSocket, getSocket, isSocketConnected } from "@/lib/socket";
import useBattleDataStore from "@/stores/battleDataStore";
import useLocaleStore from "@/stores/localeStore";
import useSocketStore from "@/stores/socketSettingStore";
import { BattleDataStateJson } from "@/types/mics";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
const themes = [
{ label: "Winter" },
{ label: "Night" },
{ label: "Cupcake" },
{ label: "Coffee" },
];
export default function Header() {
const { changeTheme } = useChangeTheme()
const { locale, setLocale } = useLocaleStore()
const {
totalAV,
totalDamage,
damagePerAV,
turnHistory,
lineup,
loadBattleDataFromJSON,
} = useBattleDataStore()
const router = useRouter()
const transI18n = useTranslations("DataAnalysisPage")
const { host, port, status, connectionType, setHost, setPort, setStatus, setConnectionType } = useSocketStore();
const [message, setMessage] = useState({ text: '', type: '' });
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
const cookieLocale = document.cookie.split("; ")
.find((row) => row.startsWith("MYNEXTAPP_LOCALE"))
?.split("=")[1];
if (cookieLocale) {
setLocale(cookieLocale)
} else {
const browserLocale = navigator.language.slice(0, 2);
setLocale(browserLocale);
document.cookie = `MYNEXTAPP_LOCALE=${browserLocale};`
router.refresh()
}
}, [router, setLocale])
const changeLocale = (newLocale: string) => {
setLocale(newLocale)
document.cookie = `MYNEXTAPP_LOCALE=${newLocale};`
router.refresh()
}
useEffect(() => {
const checkStatus = () => {
const connected = isSocketConnected();
if (connected !== status) {
setStatus(connected);
}
};
checkStatus();
const intervalId = setInterval(checkStatus, 5000);
return () => clearInterval(intervalId);
}, [status, setStatus]);
useEffect(() => {
const socket = getSocket();
if (!socket) return;
return () => {
disconnectSocket()
};
}, [setStatus]);
const handleConnect = () => {
if (!host || !port) {
setMessage({ text: 'IP and Port are required', type: 'error' });
return;
}
connectSocket();
setTimeout(() => {
const connected = isSocketConnected();
setStatus(connected);
if (connected) {
setMessage({ text: 'Socket connected successfully!', type: 'success' });
} else {
setMessage({ text: 'Failed to connect. Please check IP and port.', type: 'error' });
}
}, 2000);
};
const checkConnection = async () => {
setMessage({ text: 'Checking connection...', type: 'info' });
let isConnect = false
try {
isConnect = await checkConnectTcpApi()
} catch {
}
if (!isConnect) {
setMessage({ text: 'Connection bridge to TCP Server failed', type: 'error' })
} else {
setMessage({ text: 'Connection OK!', type: 'success' });
}
}
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
setIsModalOpen(true);
modal.showModal();
}
};
// Close modal handler
const handleCloseModal = (modalId: string) => {
setIsModalOpen(false);
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.close()
}
};
// Handle ESC key to close modal
useEffect(() => {
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isModalOpen) {
handleCloseModal("character_detail_modal");
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
}, [isModalOpen]);
return (
<div className="navbar bg-base-100 shadow-md sticky top-0 z-50 px-3 py-1">
<div className="navbar-start">
{/* Mobile menu dropdown */}
<div className="dropdown">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm lg:hidden hover:bg-base-200 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</div>
<ul
tabIndex={0}
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-10 mt-3 w-52 p-2 shadow-md border border-base-200"
>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
>
{transI18n("loadData")}
</button>
</li>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
>
{transI18n("exportData")}
</button>
</li>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
onClick={() => handleShow("socket_connection_modal")}
>
{transI18n("connectSetting")}
</button>
</li>
</ul>
</div>
{/* Logo */}
<a className=" flex flex-col items-start text-left gap-0 hover:scale-105 px-2">
<h1 className="text-xl font-bold">
<span className="text-emerald-500">Firefly Analy</span>
<span className="bg-clip-text text-transparent bg-gradient-to-r from-emerald-400 via-orange-500 to-red-500">
sis
</span>
</h1>
<p className="text-sm text-gray-500">For Veritas</p>
</a>
</div>
{/* Desktop navigation */}
<div className="navbar-center hidden lg:flex">
<ul className="menu menu-horizontal gap-1">
<li>
<>
<input
type="file"
accept="application/json"
id="battle-data-upload"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
importBattleData(file, loadBattleDataFromJSON)
.then(() => console.log('Data loaded'))
.catch(err => alert('Failed to load data: ' + err.message));
}
}}
/>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
onClick={() => document.getElementById('battle-data-upload')?.click()}
>
{transI18n("loadData")}
</button>
</>
</li>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
onClick={() => exportBattleData({ totalAV, totalDamage, turnHistory, damagePerAV, lineup } as BattleDataStateJson)}
>
{transI18n("exportData")}
</button>
</li>
<li>
<button
className="px-3 py-2 hover:bg-base-200 rounded-md transition-all duration-200 font-medium"
onClick={() => handleShow("socket_connection_modal")}
>
{transI18n("connectSetting")}
</button>
</li>
</ul>
</div>
{/* Right side items */}
<div className="navbar-end gap-2">
<div className="px-2">
<div className="flex items-center space-x-2 p-1.5 rounded-full shadow-md">
<div className={`text-sm italic ${status ? 'text-green-500' : 'text-red-500'}`}>
{status ? transI18n("connected") : transI18n("unconnected")}
</div>
<div className={`w-3 h-3 rounded-full ${status ? 'bg-green-500' : 'bg-red-500'}`}></div>
</div>
</div>
{/* Language selector - REFINED */}
<div className="dropdown dropdown-end">
<div className="flex items-center gap-1 border border-base-300 rounded text-sm px-1.5 py-0.5 hover:bg-base-200 cursor-pointer transition-all duration-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-4">
<path strokeLinecap="round" strokeLinejoin="round" d="m10.5 21 5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 0 1 6-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 0 1-3.827-5.802" />
</svg>
<select
className="outline-none bg-base-200 cursor-pointer text-sm pr-0"
value={locale}
onChange={(e) => changeLocale(e.target.value)}
>
<option value="vi">VI</option>
<option value="en">EN</option>
<option value="cn">CN</option>
<option value="jp">JP</option>
<option value="kr">KR</option>
</select>
</div>
</div>
<div title="Change Theme" className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-sm hover:bg-base-200 transition-all duration-200 px-2">
<svg
width={16}
height={16}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="h-4 w-4 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"
/>
</svg>
<svg
width="10px"
height="10px"
className="ml-1 h-2 w-2 fill-current opacity-60"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2048 2048"
>
<path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z" />
</svg>
</div>
<div
tabIndex={0}
className="dropdown-content bg-base-100 text-base-content rounded-lg top-px overflow-y-auto border border-base-200 shadow-md mt-12"
>
<ul className="menu w-40 p-1">
{themes.map((theme) => (
<li
key={theme.label}
onClick={() => {
if (changeTheme) changeTheme(theme.label.toLowerCase());
}}
>
<button
className="gap-2 px-2 py-1.5 hover:bg-base-200 rounded text-sm transition-all duration-200"
data-set-theme={theme.label.toLowerCase()}
data-act-class="[&_svg]:visible"
>
<div
data-theme={theme.label.toLowerCase()}
className="bg-base-100 grid shrink-0 grid-cols-2 gap-0.5 rounded p-1 shadow-sm"
>
<div className="bg-base-content size-1 rounded-full" />
<div className="bg-primary size-1 rounded-full" />
<div className="bg-secondary size-1 rounded-full" />
<div className="bg-accent size-1 rounded-full" />
</div>
<div className="text-sm">{theme.label}</div>
</button>
</li>
))}
</ul>
</div>
</div>
{/* GitHub Link */}
<Link
className='btn btn-ghost btn-sm btn-circle hover:bg-base-200 transition-all duration-200'
href={"https://github.com/AzenKain/SR-Analysis"}
target="_blank"
rel="noopener noreferrer"
>
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512">
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
</Link>
</div>
<dialog id="socket_connection_modal" className="modal sm:modal-middle backdrop-blur-sm">
<div className="modal-box w-11/12 max-w-7xl bg-base-100 text-base-content border border-purple-500/50 shadow-lg shadow-purple-500/20">
<div className="sticky top-0 z-10">
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
onClick={() => handleCloseModal("socket_connection_modal")}
>
✕
</motion.button>
</div>
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-400">
{transI18n("socketConnection").toUpperCase()}
</h3>
</div>
<div className="px-6 py-4">
{/* Select connection type */}
<div className="form-control grid grid-cols-1 w-full mb-6">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("connectionType")}</span>
</label>
<select
className="select select-bordered border-purple-500/30 focus:border-purple-500 bg-base-200"
value={connectionType}
onChange={(e) => setConnectionType(e.target.value)}
>
<option value="FireflyPSLocal">Firefly PS Local</option>
<option value="Other">{transI18n("other")}</option>
</select>
</div>
{/* Show host/port if Other */}
{connectionType === "Other" && (
<div className="flex flex-col md:flex-row md:space-x-4 mb-6">
<div className="form-control w-full mb-4 md:mb-0">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("host")}</span>
</label>
<input
type="text"
placeholder={transI18n("hostPlaceHolder")}
className="input input-bordered w-full border-purple-500/30 focus:border-purple-500 bg-base-200"
value={host}
onChange={(e) => setHost(e.target.value)}
/>
</div>
<div className="form-control w-full">
<label className="label">
<span className="label-text font-semibold text-purple-300">{transI18n("port")}</span>
</label>
<input
type="number"
placeholder={transI18n("portPlaceHolder")}
className="input input-bordered w-full border-purple-500/30 focus:border-purple-500 bg-base-200"
value={port || ''}
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
/>
</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="flex flex-col sm:flex-row justify-between items-center mt-6 mb-2">
<div className="mb-4 sm:mb-0">
<span className="text-md mr-2">{transI18n("status")}:</span>
<span className={`badge ${status ? 'badge-success' : 'badge-error'} badge-lg`}>
{status ? transI18n("connected") : transI18n("unconnected")}
</span>
</div>
<div className="flex space-x-2">
<button
className={`btn btn-primary`}
onClick={handleConnect}
>
{transI18n("connect")}
</button>
<button
className={`btn btn-secondary`}
onClick={checkConnection}
>
{transI18n("checkGameConnect")}
</button>
</div>
</div>
</div>
</div>
</dialog>
</div>
)
}
+274
View File
@@ -0,0 +1,274 @@
"use client";
import useAvatarDataStore from "@/stores/avatarDataStore";
import useBattleDataStore from "@/stores/battleDataStore";
import CharacterCard from "../card/characterCard";
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
import { AvatarType } from "@/types";
import useLocaleStore from "@/stores/localeStore";
import { getNameChar } from '@/helper/getNameChar';
import SkillBarChart from "../chart/skillBarChart";
import SkillPieChart from "../chart/skillPieChart";
import { motion } from "framer-motion";
import { DamageLineForOne } from "../chart/damageLineForOne";
import { DamagePerCycleForOne } from "../chart/damagePerCycleForOne";
import { useCalcTotalDmgAvatar, useCalcTotalTurnAvatar } from "@/hooks/useCalcAvatarData";
export default function LineupBar() {
const [selectedCharacter, setSelectedCharacter] = useState<AvatarType | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const transI18n = useTranslations("DataAnalysisPage");
const { lineup } = useBattleDataStore();
const { listAvatar } = useAvatarDataStore();
const { locale } = useLocaleStore();
const [modeBar, setModeBar] = useState<0 | 1 | 2>(1);
const [modeLine, setModeLine] = useState<0 | 1>(1);
const totalDamage = useCalcTotalDmgAvatar(selectedCharacter ? Number(selectedCharacter.id) : 0);
const totalTurn = useCalcTotalTurnAvatar(selectedCharacter ? Number(selectedCharacter.id) : 0)
const lineupAvatars = listAvatar.filter(item =>
lineup.some(av => av.avatarId.toString() === item.id)
);
const handleShow = (modalId: string, item: AvatarType) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
setSelectedCharacter(item);
setIsModalOpen(true);
modal.showModal();
}
};
// Close modal handler
const handleCloseModal = (modalId: string) => {
setIsModalOpen(false);
setSelectedCharacter(null);
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
modal.close()
}
};
// Handle ESC key to close modal
useEffect(() => {
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isModalOpen) {
handleCloseModal("character_detail_modal");
}
};
window.addEventListener('keydown', handleEscKey);
return () => window.removeEventListener('keydown', handleEscKey);
}, [isModalOpen]);
return (
<div className="p-4 rounded-lg shadow-lg w-full h-full min-h-[74vh]">
<motion.h2
className="text-center text-xl lg:text-2xl pb-2 font-bold text-transparent bg-clip-text bg-gradient-to-r from-pink-500 via-purple-500 to-cyan-500"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{transI18n("lineupInfo")}
</motion.h2>
<div className="relative h-full pt-2 max-h-[90vh] border-t-2 border-accent">
{lineupAvatars.length === 0 ? (
<div className="h-full flex justify-center items-start">
<p className="text-base-content opacity-50">{transI18n("noCharactersInLineup")}</p>
</div>
) : (
<div className="h-full w-full overflow-y-auto overflow-x-hidden custom-scrollbar rounded-lg">
<style jsx>{`
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--p)) hsl(var(--b3));
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: hsl(var(--b3));
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--p));
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--pf));
}
.custom-scrollbar::-webkit-scrollbar-button {
display: none;
height: 0;
width: 0;
}
`}</style>
<div className="grid grid-cols-1 w-full justify-items-center items-start">
{lineupAvatars.map((item, index) => (
<motion.div
key={item.id}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
whileHover={{ scale: 1.05 }}
className="cursor-pointer"
onClick={() => handleShow("character_detail_modal", item)}
>
<CharacterCard data={item} />
</motion.div>
))}
</div>
</div>
)}
{/* Character Detail Modal */}
<dialog id="character_detail_modal" className="modal backdrop-blur-sm">
<div className="modal-box w-11/12 max-w-7xl bg-gradient-to-b bg-base-100 text-base-content border-purple-500 shadow-lg shadow-purple-500/20">
<div className="sticky top-0 z-10">
<motion.button
whileHover={{ scale: 1.1, rotate: 90 }}
transition={{ duration: 0.2 }}
className="btn btn-circle btn-md absolute right-2 top-2 bg-red-600 hover:bg-red-700 text-white border-none"
onClick={() => handleCloseModal("character_detail_modal")}
>
✕
</motion.button>
</div>
<div className="border-b border-purple-500/30 px-6 py-4 mb-4">
<h3 className="font-bold text-2xl text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-cyan-400">
{selectedCharacter ? getNameChar(locale, selectedCharacter).toUpperCase() : ""}
</h3>
</div>
{selectedCharacter && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="grid grid-cols-1 md:grid-cols-2 gap-6 p-2"
>
<div className="md:col-span-2 bg-base-200 rounded-lg p-4 shadow-md">
<h4 className="text-lg font-semibold mb-2 text-pink-500">{transI18n("characterInformation")}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2">
<div className="flex flex-col space-y-2 relative">
<p>
{transI18n("id")}: <span className="font-bold">{selectedCharacter.id}</span>
</p>
<p className="flex items-center space-x-2">
<span>{transI18n("path")}:</span>
<span className="font-bold">{transI18n(selectedCharacter.baseType.toLowerCase())}</span>
{selectedCharacter.baseType && (
<img
loading="lazy"
src={`https://api.hakush.in/hsr/UI/pathicon/${selectedCharacter.baseType.toLowerCase()}.webp`}
className="w-6 h-6"
alt={selectedCharacter.baseType.toLowerCase()}
/>
)}
</p>
<p>
{transI18n("rarity")}: <span className="font-bold">{selectedCharacter.rank === "CombatPowerAvatarRarityType5" ? "5*" : "4*"}</span>
</p>
<p className="flex items-center space-x-2">
<span>{transI18n("element")}:</span>
<span className="font-bold">{transI18n(selectedCharacter.damageType.toLowerCase())}</span>
{selectedCharacter.damageType && (
<img
loading="lazy"
src={`https://api.hakush.in/hsr/UI/element/${selectedCharacter.damageType.toLowerCase()}.webp`}
className="w-6 h-6"
alt={selectedCharacter.damageType.toLowerCase()}
/>
)}
</p>
</div>
</div>
<div className="flex justify-center items-center">
{selectedCharacter && (
<img
src={`https://api.hakush.in/hsr/UI/avatarshopicon/${selectedCharacter.id}.webp`}
alt={getNameChar(locale, selectedCharacter)}
className="h-32 w-32 object-cover rounded-full border-2 border-purple-500"
/>
)}
</div>
</div>
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md">
<p className="mt-2 font-bold text-lg text-cyan-500">{transI18n("totalTurn")}: <span className="text-base-content">{totalTurn.toFixed(2)}</span></p>
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md">
<h4 className="text-lg font-semibold mb-2 text-purple-500">{transI18n("totalDamage")}: <span className="text-base-content">{totalDamage.toFixed(2)}</span></h4>
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
<h4 className="text-lg font-semibold mb-4 text-purple-500">{transI18n("skillDamageBreakdown")}</h4>
<SkillBarChart avatarId={Number(selectedCharacter.id) ?? 0} />
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-semibold mb-4 text-cyan-500">{transI18n("damageOverTime")}</h4>
<div className="flex gap-2">
{[0, 1].map((m) => (
<button
key={m}
onClick={() => setModeLine(m as 0 | 1)}
className={`btn btn-sm ${modeLine === m ? "btn-accent" : "btn-ghost"}`}
>
{transI18n("style")} {m}
</button>
))}
</div>
</div>
<DamageLineForOne avatarId={Number(selectedCharacter.id) ?? 0} mode={modeLine} />
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
<h4 className="text-lg font-semibold mb-4 text-cyan-500">{transI18n("skillUsageDistribution")}</h4>
<SkillPieChart avatarId={Number(selectedCharacter.id) ?? 0} />
</div>
<div className="bg-base-200 rounded-lg p-4 shadow-md max-h-11/12">
<div className="flex justify-between items-center mb-4">
<h4 className="text-lg font-semibold text-purple-500">{transI18n("damagerPerCycle")}</h4>
<div className="flex gap-2">
{[0, 1, 2].map((m) => (
<button
key={m}
onClick={() => setModeBar(m as 0 | 1 | 2)}
className={`btn btn-sm ${modeBar === m ? "btn-accent" : "btn-ghost"}`}
>
{transI18n("style")} {m}
</button>
))}
</div>
</div>
<DamagePerCycleForOne avatarId={Number(selectedCharacter.id) ?? 0} mode={modeBar} />
</div>
</motion.div>
)}
</div>
</dialog>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
"use client";
import { PropsWithChildren, useContext } from "react";
import { ThemeContext } from "./themeContext";
export function ClientThemeWrapper({ children }: PropsWithChildren) {
const { theme } = useContext(ThemeContext);
return <div data-theme={theme} className="h-full">{children}</div>;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./clientThemeWrapper"
export * from "./themeContext"
@@ -0,0 +1,41 @@
"use client";
import { createContext, PropsWithChildren, useEffect, useState } from "react";
interface ThemeContextType {
theme?: string;
changeTheme?: (nextTheme: string | null) => void;
}
export const ThemeContext = createContext<ThemeContextType>({});
export const ThemeProvider = ({ children }: PropsWithChildren) => {
const [theme, setTheme] = useState<string>("light");
useEffect(() => {
if (typeof window !== "undefined") {
const storedTheme = localStorage.getItem("theme");
if (storedTheme) setTheme(storedTheme);
}
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const changeTheme = (nextTheme: string | null) => {
console.log(nextTheme)
if (nextTheme) {
setTheme(nextTheme);
if (typeof window !== "undefined") {
localStorage.setItem("theme", nextTheme);
}
} else {
setTheme((prev) => (prev === "light" ? "night" : "light"));
if (typeof window !== "undefined") {
localStorage.setItem("theme", theme);
}
}
};
return (
<ThemeContext.Provider value={{ theme, changeTheme }}>
{children}
</ThemeContext.Provider>
);
};