feat: initialize core components and multi-language support structures
Gitea Auto Deploy / Deploy-Container (push) Successful in 38s

This commit is contained in:
2026-08-28 00:56:32 +07:00
parent 0f75a8ba7b
commit ff4342efc0
22 changed files with 1548 additions and 425 deletions
+76 -15
View File
@@ -77,6 +77,8 @@ export default function ActionBar() {
return avatar?.profileList?.[avatar?.profileSelect] ?? avatar?.profileList?.[0] ?? null;
}, [avatarSelected, avatars]);
const [profileToDelete, setProfileToDelete] = useState<{ id: number; name: string } | null>(null);
const listProfile = useMemo(() => {
if (!avatarSelected) return [];
const avatar = avatars[avatarSelected.ID];
@@ -93,13 +95,13 @@ export default function ActionBar() {
};
const newListProfile = [...listProfile, newProfile];
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile, profileSelect: newListProfile.length - 1 })
toast.success("Profile created successfully")
toast.success(transI18n("profileCreatedSuccessfully"))
} else if (formState === "EDIT" && profileCurrent && avatarSelected && profileEdit !== -1) {
const newListProfile = listProfile.map((p, idx) =>
idx === profileEdit ? { ...p, profile_name: profileName } : p
);
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile })
toast.success("Profile updated successfully")
toast.success(transI18n("profileUpdatedSuccessfully"))
}
handleCloseModal("update_profile_modal");
};
@@ -129,22 +131,33 @@ export default function ActionBar() {
setAvatar({ ...avatars[avatarSelected.ID], profileSelect: profileId })
const selectedProfile = avatars[avatarSelected.ID].profileList?.[profileId];
if (selectedProfile) {
toast.success(`Profile changed to: ${selectedProfile.profile_name}`)
toast.success(`${transI18n("profileSwitched")}: ${selectedProfile.profile_name}`)
}
}
const handleDeleteProfile = (profileId: number, e: React.MouseEvent) => {
const requestDeleteProfile = (profileId: number, e: React.MouseEvent) => {
e.stopPropagation()
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`)
}
setProfileToDelete({ id: profileId, name: targetProfile.profile_name });
handleShow("delete_profile_confirm_modal");
}
const handleConfirmDeleteProfile = () => {
if (!avatarSelected || !profileToDelete || !avatars[avatarSelected.ID]) return;
const currentSelect = avatars[avatarSelected.ID].profileSelect;
const newListProfile = listProfile.filter((_, idx) => idx !== profileToDelete.id);
const nextSelect = currentSelect >= profileToDelete.id ? Math.max(0, currentSelect - 1) : currentSelect;
setAvatar({ ...avatars[avatarSelected.ID], profileList: newListProfile, profileSelect: nextSelect })
toast.success(transI18n("profileDeletedSuccessfully"))
handleCloseModal("delete_profile_confirm_modal");
setProfileToDelete(null);
}
const handleCancelDeleteProfile = () => {
handleCloseModal("delete_profile_confirm_modal");
setProfileToDelete(null);
}
const handleConnectOrSyncPS = async () => {
@@ -193,7 +206,7 @@ export default function ActionBar() {
</div>
<div className="flex justify-end gap-2 pt-2">
<button className="btn btn-ghost" onClick={() => setIsOpenCreateProfile(false)}>
Cancel
{transI18n("cancel")}
</button>
<button className="btn btn-primary" onClick={handleUpdateProfile}>
{formState === "CREATE" ? transI18n("create") : transI18n("update")}
@@ -320,14 +333,14 @@ export default function ActionBar() {
setIsOpenCreateProfile(true)
handleShow("update_profile_modal")
}}
title="Edit Profile"
title={transI18n("editProfile")}
>
<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"
onClick={(e) => requestDeleteProfile(index, e)}
title={transI18n("deleteProfile")}
>
<Trash2 className="w-3 h-3" />
</button>
@@ -490,6 +503,7 @@ export default function ActionBar() {
{title}
</h3>
<button
type="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}
>
@@ -505,6 +519,53 @@ export default function ActionBar() {
</form>
</dialog>
))}
{/* Delete Profile Confirmation Modal */}
<dialog id="delete_profile_confirm_modal" className="modal modal-bottom sm:modal-middle">
<div className="modal-box max-w-md bg-base-100/95 backdrop-blur-xl border border-white/10 shadow-2xl rounded-2xl p-6 overflow-hidden relative">
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 w-32 h-32 bg-rose-500/15 rounded-full blur-2xl" />
<div className="relative z-10 text-center space-y-4">
<div className="w-12 h-12 rounded-2xl bg-rose-500/15 text-rose-400 border border-rose-500/30 flex items-center justify-center mx-auto shadow-[0_0_20px_rgba(244,63,94,0.2)]">
<Trash2 className="w-6 h-6 stroke-[1.75]" />
</div>
<div className="space-y-1.5">
<h3 className="font-black text-lg text-base-content tracking-tight">
{transI18n("deleteProfile")}
</h3>
<p className="text-xs sm:text-sm text-base-content/70 leading-relaxed">
{transI18n("deleteProfileConfirm")}{" "}
<span className="font-bold text-amber-400 font-mono">
&quot;{profileToDelete?.name}&quot;
</span>
?
</p>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleCancelDeleteProfile}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-base-200 hover:bg-base-300 text-base-content/80 hover:text-base-content border border-white/10 transition-all cursor-pointer"
>
{transI18n("cancel")}
</button>
<button
type="button"
onClick={handleConfirmDeleteProfile}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-rose-500 to-red-600 hover:from-rose-400 hover:to-red-500 text-white shadow-md shadow-rose-500/25 hover:shadow-rose-500/40 transition-all flex items-center justify-center gap-1.5 cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>{transI18n("confirmDelete")}</span>
</button>
</div>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={handleCancelDeleteProfile}>close</button>
</form>
</dialog>
</div>
);
}
+73 -58
View File
@@ -8,6 +8,7 @@ import ParseText from '../parseText';
import useDetailDataStore from '@/stores/detailDataStore';
import { getLocaleName } from '@/helper/getName';
import { useShallow } from 'zustand/react/shallow';
import { Check } from 'lucide-react';
export default function CharacterInfoCard({
character,
@@ -35,15 +36,24 @@ export default function CharacterInfoCard({
return (
<div
className={`group rounded-2xl p-4 border transition-all duration-200 cursor-pointer select-none ${
className={`group relative overflow-hidden rounded-2xl p-3 sm:p-4 border transition-all duration-300 ease-out 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'
? 'bg-primary/10 border-primary/80 shadow-[0_0_24px_rgba(var(--color-primary-rgb,6,182,212),0.25)] ring-1 ring-primary/60 scale-[1.01]'
: 'bg-base-200/50 hover:bg-base-200/90 border-white/10 hover:border-white/25 hover:shadow-lg hover:-translate-y-1'
}`}
onClick={() => onCharacterToggle(character)}
>
{/* Character Portrait */}
<div className="relative mb-3 aspect-4/5 rounded-xl overflow-hidden bg-base-300/40 flex items-center justify-center">
{/* Selection Checkmark Badge */}
<div className={`absolute top-2.5 right-2.5 z-20 w-6 h-6 rounded-full flex items-center justify-center transition-all duration-200 ${
isSelected
? 'bg-primary text-primary-content shadow-md scale-100 ring-2 ring-primary/40'
: 'bg-black/40 border border-white/20 text-transparent scale-90 group-hover:scale-100 group-hover:border-white/40'
}`}>
<Check className={`w-3.5 h-3.5 stroke-[3] ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
</div>
{/* Character Portrait Area */}
<div className="relative mb-3 aspect-4/5 rounded-xl overflow-hidden bg-radial from-base-300/60 to-base-300/20 flex items-center justify-center">
{avatar && (
<>
<Image
@@ -53,61 +63,64 @@ export default function CharacterInfoCard({
height={512}
unoptimized
crossOrigin="anonymous"
className="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
className="w-full h-full object-contain transition-transform duration-500 ease-out group-hover:scale-108"
/>
{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>
)}
{/* Damage & Path Badges */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-10">
{damageType?.[avatar.DamageType]?.Icon && (
<div className="p-1 rounded-lg bg-black/65 backdrop-blur-md border border-white/15 shadow-sm" title={avatar.DamageType}>
<Image
width={20}
height={20}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${damageType[avatar.DamageType].Icon}`}
className="w-4 h-4 object-contain"
alt={avatar.DamageType.toLowerCase()}
/>
</div>
)}
{baseType?.[avatar.BaseType]?.Icon && (
<div className="p-1 rounded-lg bg-black/65 backdrop-blur-md border border-white/15 shadow-sm" title={avatar.BaseType}>
<Image
width={20}
height={20}
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${baseType[avatar.BaseType].Icon}`}
className="w-4 h-4 object-contain"
alt={avatar.BaseType.toLowerCase()}
/>
</div>
)}
</div>
</>
)}
</div>
{/* Character Name and Level Badge */}
<div className="text-center mb-3">
{/* Character Name & Badges */}
<div className="text-center mb-2.5">
<ParseText
className="text-base font-bold line-clamp-1"
className="text-sm sm:text-base font-bold line-clamp-1 tracking-tight"
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">
<div className="flex items-center justify-center gap-2 mt-1.5">
<span className="text-[11px] font-mono font-semibold px-2 py-0.5 rounded-md bg-base-300/80 text-base-content/90 border border-white/5">
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">
<span className="text-[11px] font-mono font-bold px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-400 border border-amber-500/30">
E{character.rank}
</span>
</div>
</div>
{/* Relics Mini-grid */}
{/* Relics Grid (2 rows of 3, no overflow) */}
{character.relics.length > 0 && (
<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">
<div className="grid grid-cols-3 gap-1.5 sm:gap-2 justify-items-center py-1.5 px-2.5 rounded-xl bg-base-300/30 w-fit mx-auto mb-2.5">
{character.relics.map((relic, index) => (
<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">
<div className="w-7 h-7 sm:w-8 sm:h-8 rounded-lg flex items-center justify-center bg-base-100/90 border border-amber-500/30 hover:border-amber-400 transition-colors shadow-xs">
<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"
@@ -115,10 +128,10 @@ export default function CharacterInfoCard({
crossOrigin="anonymous"
width={32}
height={32}
className="w-6 h-6 object-contain"
className="w-5 h-5 sm:w-6 sm:h-6 object-contain"
/>
</div>
<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">
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2 bg-black/90 text-[8px] font-mono font-bold text-amber-300 px-1 rounded-full border border-white/10 leading-tight">
+{relic.level}
</div>
</div>
@@ -126,29 +139,31 @@ export default function CharacterInfoCard({
</div>
)}
{/* Light Cone */}
{/* Light Cone Preview (Seamless strip) */}
{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">
<div className="pt-2 border-t border-white/10 flex items-center gap-2.5">
<div className="h-12 w-9 shrink-0 flex items-center justify-center rounded bg-base-300/40 overflow-hidden">
<Image
unoptimized
crossOrigin="anonymous"
src={`${process.env.CDN_URL}/${mapLightCone?.[character.lightcone.item_id.toString()]?.Image?.ImagePath}`}
alt="Lightcone"
width={160}
height={200}
className="h-full w-auto object-contain rounded-lg"
width={72}
height={96}
className="h-full w-auto object-contain"
/>
</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 className="flex-1 min-w-0 text-left">
<div className="text-xs font-semibold truncate leading-tight">
<ParseText
text={getLocaleName(locale, mapLightCone?.[character.lightcone.item_id.toString()]?.Name)}
locale={locale}
/>
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] font-mono text-base-content/70">Lv.{character.lightcone.level}</span>
<span className="text-[10px] font-mono font-bold text-primary">S{character.lightcone.rank}</span>
</div>
</div>
</div>
)}
+46 -29
View File
@@ -5,6 +5,7 @@ import useUserDataStore from "@/stores/userDataStore";
import { useShallow } from "zustand/react/shallow";
import Image from "next/image";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
interface RelicCardProps {
slot: string
@@ -26,36 +27,23 @@ const getRarityColor = (rarity: string) => {
}
};
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>
);
const slotIconMap: Record<string, { icon: string; key: string }> = {
'1': { icon: '/relics/HEAD.png', key: 'HEAD' },
'2': { icon: '/relics/HAND.png', key: 'HAND' },
'3': { icon: '/relics/BODY.png', key: 'BODY' },
'4': { icon: '/relics/FOOT.png', key: 'FOOT' },
'5': { icon: '/relics/NECK.png', key: 'NECK' },
'6': { icon: '/relics/OBJECT.png', key: 'OBJECT' },
'HEAD': { icon: '/relics/HEAD.png', key: 'HEAD' },
'HAND': { icon: '/relics/HAND.png', key: 'HAND' },
'BODY': { icon: '/relics/BODY.png', key: 'BODY' },
'FOOT': { icon: '/relics/FOOT.png', key: 'FOOT' },
'NECK': { icon: '/relics/NECK.png', key: 'NECK' },
'OBJECT': { icon: '/relics/OBJECT.png', key: 'OBJECT' },
};
export default function RelicCard({ slot, avatarId }: RelicCardProps) {
const transI18n = useTranslations("DataPage");
const { avatars } = useUserDataStore(
useShallow((state) => ({ avatars: state.avatars }))
)
@@ -63,6 +51,9 @@ export default function RelicCard({ slot, avatarId }: RelicCardProps) {
useShallow((state) => ({ selectedRelicSlot: state.selectedRelicSlot }))
)
const slotInfo = slotIconMap[slot];
const slotDisplayName = slotInfo ? (transI18n(slotInfo.key) || slotInfo.key) : slot;
const relicDetail = useMemo(() => {
const avatar = avatars[avatarId];
if (avatar) {
@@ -103,7 +94,20 @@ export default function RelicCard({ slot, avatarId }: RelicCardProps) {
</div>
</div>
{getSlotIconAndName(slot)}
{slotInfo && (
<div className="flex items-center gap-1.5 justify-center mt-2.5">
<Image
unoptimized
crossOrigin="anonymous"
src={slotInfo.icon}
alt={slotDisplayName}
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">{slotDisplayName}</span>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center">
@@ -127,7 +131,20 @@ export default function RelicCard({ slot, avatarId }: RelicCardProps) {
</div>
</div>
{getSlotIconAndName(slot)}
{slotInfo && (
<div className="flex items-center gap-1.5 justify-center mt-2.5">
<Image
unoptimized
crossOrigin="anonymous"
src={slotInfo.icon}
alt={slotDisplayName}
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">{slotDisplayName}</span>
</div>
)}
</div>
)}
</div>
+7 -5
View File
@@ -1,8 +1,10 @@
import useLocaleStore from '@/stores/localeStore';
import { useShallow } from 'zustand/react/shallow';
import { Sparkles, Bug, Zap, Package, Calendar } from 'lucide-react';
import { useTranslations } from 'next-intl';
export default function ChangelogBar() {
const transI18n = useTranslations("DataPage");
const { changelog } = useLocaleStore(
useShallow((state) => ({ changelog: state.changelog }))
)
@@ -36,13 +38,13 @@ export default function ChangelogBar() {
const getTypeLabel = (type: string) => {
switch (type) {
case 'feature':
return 'Feature';
return transI18n("feature") || 'Feature';
case 'fix':
return 'Fix';
return transI18n("fix") || 'Fix';
case 'improvement':
return 'Improvement';
return transI18n("improvement") || 'Improvement';
default:
return 'Update';
return transI18n("update") || 'Update';
}
};
@@ -59,7 +61,7 @@ export default function ChangelogBar() {
{getTypeLabel(change.type)}
</span>
<h3 className="font-extrabold text-sm sm:text-base tracking-tight">
Version {change.version}
{transI18n("version") || "Version"} {change.version}
</h3>
</div>
+2 -2
View File
@@ -646,8 +646,8 @@ export default function Header() {
: 'max-w-[92vw] sm:max-w-[88vw] max-h-[90vh]'
} max-h-[90vh] flex flex-col 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">
<div className="sticky top-0 z-20 flex items-center justify-between px-4 py-3 sm:px-6 sm:py-4 border-b border-base-content/10 bg-base-100 shrink-0">
<h3 className="font-bold text-lg sm:text-xl tracking-tight bg-clip-text text-transparent bg-linear-to-r from-primary via-accent to-secondary">
{title}
</h3>
<button
+295 -109
View File
@@ -1,4 +1,5 @@
"use client"
import { useState } from "react";
import CharacterInfoCard from "../card/characterInfoCard";
import useEnkaStore from "@/stores/enkaStore";
@@ -13,6 +14,18 @@ import { avatarEnkaDetailSchema, enkaResponseSchema } from "@/zod";
import { z } from "zod";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
import {
Search,
CheckCheck,
RotateCcw,
AlertCircle,
Sparkles,
ArrowRight,
Users,
Globe,
UserCheck,
Loader2
} from "lucide-react";
type AvatarEnkaDetailInput = z.infer<typeof avatarEnkaDetailSchema>;
@@ -51,7 +64,7 @@ export default function EnkaImport() {
setEnkaData: state.setEnkaData,
}))
);
const transI18n = useTranslations("DataPage")
const transI18n = useTranslations("DataPage");
const { avatars, setAvatar } = useUserDataStore(
useShallow((state) => ({
avatars: state.avatars,
@@ -62,48 +75,66 @@ export default function EnkaImport() {
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]) ?? []
);
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const validCharacters = enkaData?.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]) ?? [];
const handlerFetchData = async () => {
if (!uidInput) {
setError(transI18n("pleaseEnterUid"))
const cleanUid = uidInput.trim();
if (!cleanUid) {
setErrorMsg(transI18n("pleaseEnterUid"));
return;
}
setIsLoading(true)
setIsLoading(true);
setErrorMsg("");
try {
const data: unknown = await SendDataThroughProxy({data: {serverUrl: "https://enka.network/api/hsr/uid/" + uidInput, method: "GET"}})
const parsed = enkaResponseSchema.safeParse(data)
const data: unknown = await SendDataThroughProxy({
data: {
serverUrl: "https://enka.network/api/hsr/uid/" + cleanUid,
method: "GET"
}
});
const parsed = enkaResponseSchema.safeParse(data);
if (!parsed.success) {
setEnkaData(null)
setSelectedCharacters([])
setError(transI18n("failedToFetchEnkaData"))
return
setEnkaData(null);
setSelectedCharacters([]);
setErrorMsg(transI18n("failedToFetchEnkaData"));
return;
}
setEnkaData(parsed.data as EnkaResponse)
setSelectedCharacters(parsed.data.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId]).map(toCharacterInfoCard))
setError("")
setEnkaData(parsed.data as EnkaResponse);
const validChars = parsed.data.detailInfo.avatarDetailList
.filter((character) => mapAvatar?.[character.avatarId])
.map(toCharacterInfoCard);
setSelectedCharacters(validChars);
setErrorMsg("");
} catch {
setEnkaData(null)
setSelectedCharacters([])
setError(transI18n("failedToFetchEnkaData"))
setEnkaData(null);
setSelectedCharacters([]);
setErrorMsg(transI18n("failedToFetchEnkaData"));
} finally {
setIsLoading(false)
setIsLoading(false);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handlerFetchData();
}
};
const handleCharacterToggle = (character: CharacterInfoCardType) => {
if (selectedCharacters.some((selectedCharacter) => selectedCharacter.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selectedCharacter) => selectedCharacter.key !== character.key));
if (selectedCharacters.some((selected) => selected.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selected) => selected.key !== character.key));
return;
}
setSelectedCharacters([...selectedCharacters, character]);
@@ -121,29 +152,32 @@ export default function EnkaImport() {
const handleImport = () => {
if (selectedCharacters.length === 0) {
setError(transI18n("pleaseSelectAtLeastOneCharacter"));
setErrorMsg(transI18n("pleaseSelectAtLeastOneCharacter"));
return;
}
if (!enkaData) {
setError(transI18n("noDataToImport"));
setErrorMsg(transI18n("noDataToImport"));
return;
}
setError("");
const listAvatars = { ...avatars }
const parsed = enkaResponseSchema.safeParse(enkaData)
setErrorMsg("");
const listAvatars = { ...avatars };
const parsed = enkaResponseSchema.safeParse(enkaData);
if (!parsed.success) {
setError(transI18n("failedToFetchEnkaData"));
setErrorMsg(transI18n("failedToFetchEnkaData"));
return;
}
const filterData = parsed.data.detailInfo.avatarDetailList.filter((character) => mapAvatar?.[character.avatarId] && selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatarId))
const filterData = parsed.data.detailInfo.avatarDetailList.filter((character) =>
mapAvatar?.[character.avatarId] && selectedCharacters.some((selected) => selected.avatar_id === character.avatarId)
);
filterData.forEach((character) => {
const currentAvatar = listAvatars[character.avatarId.toString()]
const currentAvatar = listAvatars[character.avatarId.toString()];
if (currentAvatar && Object.keys(currentAvatar).length !== 0) {
const newProfileList = [...(currentAvatar.profileList || [])]
const newProfile = converterOneEnkaDataToAvatarStore(character, newProfileList.length)
const newProfileList = [...(currentAvatar.profileList || [])];
const newProfile = converterOneEnkaDataToAvatarStore(character, newProfileList.length);
if (newProfile) {
newProfileList.push(newProfile)
newProfileList.push(newProfile);
}
const newAvatar = {
...currentAvatar,
@@ -158,92 +192,244 @@ export default function EnkaImport() {
},
profileList: newProfileList,
profileSelect: newProfile ? newProfileList.length - 1 : currentAvatar.profileSelect,
}
setAvatar(newAvatar)
};
setAvatar(newAvatar);
}
})
setIsOpenImport(false)
toast.success(transI18n("importEnkaDataSuccess"))
});
setIsOpenImport(false);
toast.success(transI18n("importEnkaDataSuccess"));
};
return (
<div className="w-full space-y-6">
{/* Header */}
<div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">HSR UID</h1>
<div className="flex flex-wrap gap-3 items-center">
<input
type="text"
value={uidInput}
onChange={(e) => setUidInput(e.target.value)}
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-sm btn-success rounded-xl font-bold">
{transI18n("getData")}
</button>
{selectedCharacters.length > 0 && (
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
{transI18n("import")}
</button>
)}
{/* Top Bar: Title & UID Input */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 sm:gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest rounded-md bg-emerald-500/15 text-emerald-400 border border-emerald-500/25">
Enka.Network Live
</span>
<span className="text-xs text-base-content/50 font-medium">Showcase Fetcher</span>
</div>
<h2 className="text-xl sm:text-2xl font-black tracking-tight text-base-content flex items-center gap-2">
HSR UID Showcase
</h2>
</div>
{Error && (
<div className="text-error text-sm font-semibold mt-2">😭 {Error}</div>
)}
{/* Player Info */}
{enkaData && (
<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="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>
{/* UID Search Input Form */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 w-full md:w-auto">
<div className="relative flex-1 md:w-72">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/50 pointer-events-none" />
<input
type="text"
value={uidInput}
onChange={(e) => setUidInput(e.target.value)}
onKeyDown={handleKeyDown}
className="w-full h-10 pl-10 pr-9 rounded-xl bg-base-200/90 text-base-content border border-base-content/20 text-sm font-mono focus:border-cyan-500 focus:outline-none focus:ring-1 focus:ring-cyan-500 transition-all placeholder:font-sans placeholder:text-xs placeholder:text-base-content/40"
placeholder={transI18n("enterUidPlaceholder")}
/>
{uidInput && (
<button
type="button"
onClick={() => setUidInput("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content text-xs p-0.5 rounded-full hover:bg-base-300 transition-colors"
>
✕
</button>
)}
</div>
<button
type="button"
onClick={handlerFetchData}
disabled={isLoading}
className={`h-10 min-h-[40px] px-5 rounded-xl font-bold text-xs sm:text-sm flex items-center justify-center gap-2 select-none shrink-0 transition-all duration-200 shadow-md ${
isLoading
? 'bg-cyan-600 text-white cursor-wait opacity-90 shadow-cyan-500/30'
: 'bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white shadow-cyan-500/25 hover:shadow-cyan-500/40 hover:scale-[1.02] active:scale-[0.98] cursor-pointer'
}`}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-white shrink-0" />
<span className="text-white font-bold">{transI18n("fetchingData")}</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-white shrink-0" />
<span className="text-white font-bold">{transI18n("getData")}</span>
</>
)}
</button>
</div>
</div>
{isLoading && (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
{/* Error Message Banner */}
{errorMsg && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-semibold animate-in fade-in duration-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{/* Trailblazer Profile HUD Banner (when loaded) */}
{enkaData && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 py-3 px-4 rounded-xl bg-base-200/50 border border-white/10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/30 flex items-center justify-center text-primary font-bold text-base shrink-0">
<UserCheck className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-base-content text-sm sm:text-base truncate">
{enkaData.detailInfo.nickname}
</span>
<span className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded bg-primary/15 text-primary border border-primary/25">
Lv.{enkaData.detailInfo.level}
</span>
{enkaData.detailInfo.worldLevel && (
<span className="text-[10px] font-mono text-base-content/60 px-1.5 py-0.5 rounded bg-base-300/60">
WL {enkaData.detailInfo.worldLevel}
</span>
)}
</div>
<div className="text-[11px] font-mono text-base-content/50 mt-0.5 truncate">
UID: {enkaData.detailInfo.uid}
{enkaData.detailInfo.signature && (
<span className="ml-2 font-sans italic text-base-content/40">
&ldquo;{enkaData.detailInfo.signature}&rdquo;
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center shrink-0">
<span className="text-xs text-base-content/60">{transI18n("publicCharacters")}:</span>
<span className="badge badge-primary font-mono font-bold text-xs">
{validCharacters.length}
</span>
</div>
</div>
)}
{/* Floating Selection & Actions Bar */}
{enkaData && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 py-2.5 px-4 rounded-xl bg-base-200/60 backdrop-blur-md border border-white/10 shadow-xs">
{/* Left: Counter and Quick Select */}
<div className="flex items-center justify-between sm:justify-start gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-cyan-500" />
<span className="text-xs sm:text-sm font-bold text-base-content">
{transI18n("selectedCharacters")}:
</span>
<span className="px-2 py-0.5 rounded-full bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-mono font-bold text-xs">
{selectedCharacters.length} / {validCharacters.length}
</span>
</div>
<div className="h-4 w-px bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={selectAll}
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-cyan-400 hover:text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<CheckCheck className="w-3.5 h-3.5" />
{transI18n("selectAll")}
</button>
<button
type="button"
onClick={clearSelection}
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-rose-400 hover:text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
{transI18n("clearAll")}
</button>
</div>
</div>
{/* Right: Import CTA */}
<button
type="button"
onClick={handleImport}
disabled={selectedCharacters.length === 0}
className="h-9 px-5 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white shadow-md shadow-cyan-500/25 hover:shadow-cyan-500/40 disabled:opacity-40 disabled:pointer-events-none flex items-center justify-center gap-1.5 transition-all w-full sm:w-auto cursor-pointer"
>
<span className="text-white">{transI18n("import")}</span>
<span className="font-mono text-xs text-cyan-100">({selectedCharacters.length})</span>
<ArrowRight className="w-3.5 h-3.5 text-white" />
</button>
</div>
)}
{/* Loading Shimmer State */}
{isLoading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4 py-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-2xl bg-base-200/30 border border-white/5 animate-pulse p-3.5 space-y-3">
<div className="w-full aspect-4/5 rounded-xl bg-base-300/40" />
<div className="h-4 bg-base-300/60 rounded w-2/3 mx-auto" />
<div className="h-3 bg-base-300/40 rounded w-1/2 mx-auto" />
</div>
))}
</div>
)}
{/* Empty State (when no UID fetched yet) */}
{!isLoading && !enkaData && (
<div className="relative overflow-hidden py-12 sm:py-16 px-4 sm:px-6 rounded-2xl border border-dashed border-white/15 bg-base-200/20 text-center select-none">
{/* Ambient Glow */}
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 h-40 w-40 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="relative z-10 max-w-md mx-auto space-y-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl bg-base-300/60 border border-white/10 text-emerald-400 mx-auto flex items-center justify-center shadow-lg">
<Globe className="w-7 h-7 sm:w-8 sm:h-8 stroke-[1.5]" />
</div>
<div className="space-y-1.5">
<h3 className="text-base sm:text-lg font-bold text-base-content">
{transI18n("fetchFromEnka")}
</h3>
<p className="text-xs text-base-content/60 leading-relaxed">
{transI18n("fetchEnkaHint")}
</p>
</div>
{/* Step hints */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 sm:gap-3 pt-3 text-left border-t border-white/5">
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-emerald-400 block mb-0.5">{transI18n("step1")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("inGameProfile")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-emerald-400 block mb-0.5">{transI18n("step2")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("displayCharactersPublicly")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-emerald-400 block mb-0.5">{transI18n("step3")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("enterUidAndSync")}</p>
</div>
</div>
</div>
</div>
)}
{/* Character Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 sm:gap-6">
{validCharacters.map((character) => (
<CharacterInfoCard
key={character.avatarId}
character={toCharacterInfoCard(character)}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
))}
</div>
{!isLoading && enkaData && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
{validCharacters.map((character) => (
<CharacterInfoCard
key={character.avatarId}
character={toCharacterInfoCard(character)}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
))}
</div>
)}
</div>
);
}
+318 -136
View File
@@ -4,7 +4,7 @@ import useFreeSRStore from "@/stores/freesrStore";
import useModelStore from "@/stores/modelStore";
import useUserDataStore from "@/stores/userDataStore";
import { CharacterInfoCardType } from "@/types";
import { useState } from "react";
import { useState, useRef } from "react";
import CharacterInfoCard from "../card/characterInfoCard";
import { freeSrJsonSchema } from "@/zod";
import { toast } from "react-toastify";
@@ -13,12 +13,22 @@ import { useTranslations } from "next-intl";
import useDetailDataStore from "@/stores/detailDataStore";
import { useShallow } from "zustand/react/shallow";
import { z } from "zod";
import {
UploadCloud,
FileJson,
CheckCheck,
RotateCcw,
AlertCircle,
ArrowRight,
Users,
FileCheck
} from "lucide-react";
type FreeSRJsonInput = z.infer<typeof freeSrJsonSchema>;
const toCharacterInfoCard = (data: FreeSRJsonInput, character: FreeSRJsonInput["avatars"][string]): CharacterInfoCardType => {
const lightcone = data.lightcones.find((lightcone) => lightcone.equip_avatar === character.avatar_id)
const relics = data.relics.filter((relic) => relic.equip_avatar === character.avatar_id)
const lightcone = data.lightcones?.find((lc) => lc.equip_avatar === character.avatar_id);
const relics = data.relics?.filter((relic) => relic.equip_avatar === character.avatar_id) || [];
return {
key: character.avatar_id,
@@ -35,8 +45,8 @@ const toCharacterInfoCard = (data: FreeSRJsonInput, character: FreeSRJsonInput["
relic_id: relic.relic_id,
relic_set_id: relic.relic_set_id,
})),
}
}
};
};
export default function FreeSRImport() {
const { avatars, setAvatar } = useUserDataStore(
@@ -54,9 +64,14 @@ export default function FreeSRImport() {
useShallow((state) => ({
setIsOpenImport: state.setIsOpenImport,
}))
)
const [isLoading, setIsLoading] = useState(false)
const [Error, setError] = useState("")
);
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [fileName, setFileName] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { freeSRData, setFreeSRData, selectedCharacters, setSelectedCharacters } = useFreeSRStore(
useShallow((state) => ({
freeSRData: state.freeSRData,
@@ -64,14 +79,19 @@ export default function FreeSRImport() {
selectedCharacters: state.selectedCharacters,
setSelectedCharacters: state.setSelectedCharacters,
}))
)
const transI18n = useTranslations("DataPage")
const parsedFreeSRData = freeSrJsonSchema.safeParse(freeSRData)
const validFreeSRData = parsedFreeSRData.success ? parsedFreeSRData.data : null
);
const transI18n = useTranslations("DataPage");
const parsedFreeSRData = freeSrJsonSchema.safeParse(freeSRData);
const validFreeSRData = parsedFreeSRData.success ? parsedFreeSRData.data : null;
const availableCharacters = validFreeSRData
? Object.values(validFreeSRData.avatars).filter(it => mapAvatar?.[it.avatar_id])
: [];
const handleCharacterToggle = (character: CharacterInfoCardType) => {
if (selectedCharacters.some((selectedCharacter) => selectedCharacter.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selectedCharacter) => selectedCharacter.key !== character.key));
if (selectedCharacters.some((selected) => selected.key === character.key)) {
setSelectedCharacters(selectedCharacters.filter((selected) => selected.key !== character.key));
return;
}
setSelectedCharacters([...selectedCharacters, character]);
@@ -82,93 +102,118 @@ export default function FreeSRImport() {
};
const selectAll = () => {
const parsed = freeSrJsonSchema.safeParse(freeSRData)
if (parsed.success) {
setSelectedCharacters(Object.values(parsed.data.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => toCharacterInfoCard(parsed.data, character)));
if (validFreeSRData) {
setSelectedCharacters(availableCharacters.map((char) => toCharacterInfoCard(validFreeSRData, char)));
}
};
const handlerReadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsLoading(true)
const file = event.target.files?.[0];
if (!file) {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("pleaseSelectAFile"))
setIsLoading(false)
return
}
if (!file.name.endsWith(".json") || file.type !== "application/json") {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
setIsLoading(false)
return
const processFile = (file: File) => {
setIsLoading(true);
if (!file.name.endsWith(".json") && file.type !== "application/json") {
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
setIsLoading(false);
return;
}
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string);
const parsed = freeSrJsonSchema.safeParse(data)
if (!parsed.success) {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
return
}
setFreeSRData(parsed.data)
setError("")
setSelectedCharacters(Object.values(parsed.data.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => toCharacterInfoCard(parsed.data, character)));
} catch {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
} finally {
setIsLoading(false)
const reader = new FileReader();
reader.onload = (e) => {
try {
const raw = JSON.parse(e.target?.result as string);
const parsed = freeSrJsonSchema.safeParse(raw);
if (!parsed.success) {
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
return;
}
};
reader.onerror = () => {
setSelectedCharacters([])
setFreeSRData(null)
setError(transI18n("fileMustBeAValidJsonFile"))
setIsLoading(false)
setFreeSRData(parsed.data);
setFileName(file.name);
setErrorMsg("");
const validChars = Object.values(parsed.data.avatars)
.filter(it => mapAvatar?.[it.avatar_id])
.map((char) => toCharacterInfoCard(parsed.data, char));
setSelectedCharacters(validChars);
} catch {
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
} finally {
setIsLoading(false);
}
reader.readAsText(file);
};
reader.onerror = () => {
setSelectedCharacters([]);
setFreeSRData(null);
setFileName(null);
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
setIsLoading(false);
};
reader.readAsText(file);
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
processFile(file);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) {
processFile(file);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = () => {
setIsDragging(false);
};
const handleImport = () => {
if (selectedCharacters.length === 0) {
setError(transI18n("pleaseSelectAtLeastOneCharacter"));
setErrorMsg(transI18n("pleaseSelectAtLeastOneCharacter"));
return;
}
if (!freeSRData) {
setError(transI18n("noDataToImport"));
setErrorMsg(transI18n("noDataToImport"));
return;
}
setError("");
setErrorMsg("");
const parsed = freeSrJsonSchema.safeParse(freeSRData)
const parsed = freeSrJsonSchema.safeParse(freeSRData);
if (!parsed.success) {
setError(transI18n("fileMustBeAValidJsonFile"));
setErrorMsg(transI18n("fileMustBeAValidJsonFile"));
return;
}
const listAvatars = { ...avatars }
const filterData = Object.values(parsed.data.avatars).filter((character) => selectedCharacters.some((selectedCharacter) => selectedCharacter.avatar_id === character.avatar_id))
const listAvatars = { ...avatars };
const filterData = Object.values(parsed.data.avatars).filter((character) =>
selectedCharacters.some((selected) => selected.avatar_id === character.avatar_id)
);
filterData.forEach((character) => {
const currentAvatar = listAvatars[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)
const newProfileList = [...(currentAvatar.profileList || [])];
const newProfile = converterOneFreeSRDataToAvatarStore(parsed.data, newProfileList.length, character.avatar_id);
if (newProfile) {
newProfileList.push(newProfile)
newProfileList.push(newProfile);
}
const newAvatar = {
...currentAvatar,
@@ -180,81 +225,218 @@ export default function FreeSRImport() {
},
profileList: newProfileList,
profileSelect: newProfile ? newProfileList.length - 1 : currentAvatar.profileSelect,
}
setAvatar(newAvatar)
};
setAvatar(newAvatar);
}
})
setIsOpenImport(false)
toast.success(transI18n("importFreeSRDataSuccess"))
}
});
setIsOpenImport(false);
toast.success(transI18n("importFreeSRDataSuccess"));
};
return (
<div className="w-full space-y-6">
{/* Header */}
<div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">{transI18n("freeSRImport")}</h1>
<div className="flex flex-wrap gap-4 items-center">
<fieldset className="fieldset">
<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-sm btn-primary rounded-xl font-bold shadow-xs">
{transI18n("import")}
</button>
)}
{/* Top Bar: Title & Upload Trigger */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 sm:gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest rounded-md bg-primary/15 text-primary border border-primary/25">
FreeSR Protocol
</span>
<span className="text-xs text-base-content/50 font-medium">JSON Importer</span>
</div>
<h2 className="text-xl sm:text-2xl font-black tracking-tight text-base-content flex items-center gap-2">
{transI18n("freeSRImport")}
</h2>
</div>
{Error && (
<div className="text-error text-sm font-semibold mt-2">😭 {Error}</div>
{/* File Dropzone / Active File Badge */}
<input
type="file"
ref={fileInputRef}
accept=".json,application/json"
className="hidden"
onChange={handleFileChange}
/>
{!validFreeSRData ? (
<div
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl border border-dashed cursor-pointer transition-all duration-200 select-none w-full sm:w-auto ${
isDragging
? 'bg-primary/15 border-primary shadow-[0_0_20px_rgba(var(--color-primary-rgb,6,182,212),0.3)] scale-[1.02]'
: 'bg-base-200/50 hover:bg-base-200/80 border-white/20 hover:border-primary/50'
}`}
>
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
<UploadCloud className="w-4 h-4" />
</div>
<div className="text-left">
<p className="text-xs font-bold text-base-content flex items-center gap-1.5">
{transI18n("pickAFile")}
<span className="text-[10px] font-normal text-base-content/60">(.json)</span>
</p>
<p className="text-[11px] text-base-content/50 truncate max-w-[200px]">
{transI18n("onlySupportFreeSRJsonFile")}
</p>
</div>
</div>
) : (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2.5 w-full sm:w-auto">
<div className="flex items-center justify-between sm:justify-start gap-2 h-10 px-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/25 text-emerald-400 text-xs font-semibold">
<div className="flex items-center gap-2 min-w-0">
<FileCheck className="w-4 h-4 text-emerald-400 shrink-0" />
<span className="truncate max-w-[180px] font-mono text-xs">{fileName || "data.json"}</span>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 font-bold shrink-0">
{availableCharacters.length} {transI18n("avatars")}
</span>
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="h-10 px-3.5 rounded-xl font-semibold text-xs bg-base-200 hover:bg-base-300 text-base-content border border-base-content/15 transition-all flex items-center justify-center cursor-pointer"
>
{transI18n("changeFile")}
</button>
</div>
)}
{/* Selection Info */}
<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 && (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
{/* Error Message */}
{errorMsg && (
<div className="flex items-center gap-2 px-4 py-3 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-400 text-xs font-semibold animate-in fade-in duration-200">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
{/* Floating Selection & Actions Bar */}
{validFreeSRData && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 py-2.5 px-4 rounded-xl bg-base-200/60 backdrop-blur-md border border-white/10 shadow-xs">
{/* Left: Counter and Quick Select */}
<div className="flex items-center justify-between sm:justify-start gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-cyan-500" />
<span className="text-xs sm:text-sm font-bold text-base-content">
{transI18n("selectedCharacters")}:
</span>
<span className="px-2 py-0.5 rounded-full bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-mono font-bold text-xs">
{selectedCharacters.length} / {availableCharacters.length}
</span>
</div>
<div className="h-4 w-px bg-white/10 hidden sm:block" />
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={selectAll}
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-cyan-400 hover:text-cyan-300 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<CheckCheck className="w-3.5 h-3.5" />
{transI18n("selectAll")}
</button>
<button
type="button"
onClick={clearSelection}
className="h-8 px-2.5 rounded-lg text-xs font-semibold text-rose-400 hover:text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/25 transition-all flex items-center gap-1 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
{transI18n("clearAll")}
</button>
</div>
</div>
{/* Right: Import CTA */}
<button
type="button"
onClick={handleImport}
disabled={selectedCharacters.length === 0}
className="h-9 px-5 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white shadow-md shadow-cyan-500/25 hover:shadow-cyan-500/40 disabled:opacity-40 disabled:pointer-events-none flex items-center justify-center gap-1.5 transition-all w-full sm:w-auto cursor-pointer"
>
<span className="text-white">{transI18n("import")}</span>
<span className="font-mono text-xs text-cyan-100">({selectedCharacters.length})</span>
<ArrowRight className="w-3.5 h-3.5 text-white" />
</button>
</div>
)}
{/* Loading Shimmer State */}
{isLoading && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4 py-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-2xl bg-base-200/30 border border-white/5 animate-pulse p-3.5 space-y-3">
<div className="w-full aspect-4/5 rounded-xl bg-base-300/40" />
<div className="h-4 bg-base-300/60 rounded w-2/3 mx-auto" />
<div className="h-3 bg-base-300/40 rounded w-1/2 mx-auto" />
</div>
))}
</div>
)}
{/* Empty State (when no valid data is loaded) */}
{!isLoading && !validFreeSRData && (
<div
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className="relative overflow-hidden py-12 sm:py-16 px-4 sm:px-6 rounded-2xl border border-dashed border-white/15 bg-base-200/20 hover:bg-base-200/40 transition-all duration-300 text-center cursor-pointer group select-none"
>
{/* Ambient Glow */}
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 h-40 w-40 rounded-full bg-primary/10 blur-3xl group-hover:scale-150 transition-transform duration-700" />
<div className="relative z-10 max-w-md mx-auto space-y-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl bg-base-300/60 border border-white/10 text-primary mx-auto flex items-center justify-center shadow-lg group-hover:scale-110 group-hover:border-primary/40 transition-all duration-300">
<FileJson className="w-7 h-7 sm:w-8 sm:h-8 stroke-[1.5]" />
</div>
<div className="space-y-1.5">
<h3 className="text-base sm:text-lg font-bold text-base-content">
{transI18n("noFreeSRDataLoaded")}
</h3>
<p className="text-xs text-base-content/60 leading-relaxed">
{transI18n("dropFreeSRJsonHint")}
</p>
</div>
{/* Step hints */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5 sm:gap-3 pt-3 text-left border-t border-white/5">
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-primary block mb-0.5">{transI18n("step1")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("exportJsonFromFreeSR")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-primary block mb-0.5">{transI18n("step2")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("dropOrSelectFile")}</p>
</div>
<div className="p-3 rounded-xl bg-base-300/30">
<span className="text-[10px] font-bold text-primary block mb-0.5">{transI18n("step3")}</span>
<p className="text-[11px] text-base-content/70 leading-tight">{transI18n("syncCharacterBuilds")}</p>
</div>
</div>
</div>
</div>
)}
{/* Character Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4 sm:gap-6">
{validFreeSRData && Object.values(validFreeSRData.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
return (
{!isLoading && validFreeSRData && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
{availableCharacters.map((character) => (
<CharacterInfoCard
key={character.avatar_id}
character={toCharacterInfoCard(validFreeSRData, character)}
selectedCharacters={selectedCharacters}
onCharacterToggle={handleCharacterToggle}
/>
)
})}
</div>
))}
</div>
)}
</div>
)
);
}
+2 -1
View File
@@ -401,7 +401,8 @@ export default function QuickView() {
subAffix,
relicEffects,
mapRelicSet,
avatarSkillTree
avatarSkillTree,
transI18n
])
return (
+118 -22
View File
@@ -1,6 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Image from "next/image";
import RelicMaker from "../relicBar";
import useUserDataStore from "@/stores/userDataStore";
import { useTranslations } from "next-intl";
@@ -15,6 +16,8 @@ import useDetailDataStore from "@/stores/detailDataStore";
import { getLocaleName } from '@/helper';
import useLocaleStore from "@/stores/localeStore";
import { useShallow } from 'zustand/react/shallow';
import { Trash2 } from "lucide-react";
import { toast } from "react-toastify";
export default function RelicsInfo() {
const { avatars, setAvatar } = useUserDataStore(
@@ -78,6 +81,8 @@ export default function RelicsInfo() {
}))
)
const [relicSlotToDelete, setRelicSlotToDelete] = useState<string | null>(null);
const handleShow = (modalId: string) => {
const modal = document.getElementById(modalId) as HTMLDialogElement | null;
if (modal) {
@@ -125,6 +130,25 @@ export default function RelicsInfo() {
}
};
const requestDeleteRelic = (slot: string) => {
setRelicSlotToDelete(slot);
handleShow("delete_relic_confirm_modal");
};
const handleConfirmDelete = () => {
if (relicSlotToDelete) {
handlerDeleteRelic(relicSlotToDelete);
toast.success(transI18n("relicDeletedSuccessfully"));
handleCloseModal("delete_relic_confirm_modal");
setRelicSlotToDelete(null);
}
};
const handleCancelDelete = () => {
handleCloseModal("delete_relic_confirm_modal");
setRelicSlotToDelete(null);
};
const handlerChangeRelic = (slot: string) => {
const relic = getRelic(slot)
setSelectedRelicSlot(slot)
@@ -217,6 +241,7 @@ export default function RelicsInfo() {
for (const item of modalConfigs) {
handleCloseModal(item?.id || "")
}
handleCloseModal("delete_relic_confirm_modal")
}
};
@@ -250,23 +275,15 @@ export default function RelicsInfo() {
</div>
{/* 6-Slot Grid completely free of box-in-box */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6 py-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
{["1", "2", "3", "4", "5", "6"].map((item, index) => (
<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"
}`}
onClick={() => setSelectedRelicSlot(item)}
className={`group relative rounded-xl transition-all duration-200 cursor-pointer ${selectedRelicSlot === item
? "ring-2 ring-primary ring-offset-2 ring-offset-base-100 shadow-md"
: "hover:ring-1 hover:ring-base-content/20"
}`}
>
<RelicCard
slot={item}
@@ -276,6 +293,7 @@ export default function RelicsInfo() {
{/* Micro Actions */}
<div className="absolute top-0 right-0 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex gap-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handlerChangeRelic(item)
@@ -290,11 +308,10 @@ export default function RelicsInfo() {
{getRelic(item) && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`${transI18n("deleteRelicConfirm")} ${item}?`)) {
handlerDeleteRelic(item)
}
requestDeleteRelic(item)
}}
className="btn btn-xs btn-circle btn-error shadow-xs"
title={transI18n("deleteRelic")}
@@ -312,6 +329,7 @@ export default function RelicsInfo() {
{/* Bottom Actions Bar */}
<div className="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
disabled={!selectedRelicSlot}
onClick={() => handlerChangeRelic(selectedRelicSlot)}
className="btn btn-primary btn-sm rounded-xl font-bold shadow-xs"
@@ -319,14 +337,15 @@ export default function RelicsInfo() {
{transI18n("changeRelic")}
</button>
<button
disabled={!selectedRelicSlot}
type="button"
disabled={!selectedRelicSlot || !getRelic(selectedRelicSlot)}
onClick={(e) => {
e.stopPropagation()
if (window.confirm(`${transI18n("deleteRelicConfirm")} ${selectedRelicSlot}?`)) {
handlerDeleteRelic(selectedRelicSlot)
if (selectedRelicSlot) {
requestDeleteRelic(selectedRelicSlot)
}
}}
className="btn btn-ghost btn-sm text-error hover:bg-error/20 rounded-xl"
className="btn btn-ghost btn-sm text-error hover:bg-error/20 rounded-xl disabled:opacity-40 disabled:pointer-events-none"
>
{transI18n("deleteRelic")}
</button>
@@ -413,6 +432,7 @@ export default function RelicsInfo() {
{title}
</h3>
<button
type="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}
>
@@ -429,6 +449,82 @@ export default function RelicsInfo() {
</form>
</dialog>
))}
{/* Delete Relic Confirmation Modal */}
<dialog id="delete_relic_confirm_modal" className="modal modal-bottom sm:modal-middle">
<div className="modal-box max-w-md bg-base-100/95 backdrop-blur-xl border border-white/10 shadow-2xl rounded-2xl p-6 overflow-hidden relative">
{/* Ambient Glow */}
<div className="pointer-events-none absolute -top-12 left-1/2 -translate-x-1/2 w-32 h-32 bg-rose-500/15 rounded-full blur-2xl" />
<div className="relative z-10 text-center space-y-4">
{/* Rose Icon Badge */}
<div className="w-12 h-12 rounded-2xl bg-rose-500/15 text-rose-400 border border-rose-500/30 flex items-center justify-center mx-auto shadow-[0_0_20px_rgba(244,63,94,0.2)]">
<Trash2 className="w-6 h-6 stroke-[1.75]" />
</div>
{/* Title & Description */}
<div className="space-y-1.5">
<h3 className="font-black text-lg text-base-content tracking-tight">
{transI18n("deleteRelic")}
</h3>
<p className="text-xs sm:text-sm text-base-content/70 leading-relaxed">
{transI18n("deleteRelicConfirm")}{" "}
<span className="font-bold text-amber-400 capitalize font-mono">
{relicSlotToDelete ? (transI18n({ "1": "HEAD", "2": "HAND", "3": "BODY", "4": "FOOT", "5": "NECK", "6": "OBJECT" }[relicSlotToDelete] || relicSlotToDelete) || relicSlotToDelete) : ""}
</span>
?
</p>
</div>
{/* Relic Preview Card (if relic exists) */}
{relicSlotToDelete && getRelic(relicSlotToDelete) && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-base-200/70 border border-white/10 text-left">
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-base-300/60 border border-amber-500/30 shrink-0">
<Image
src={`${process.env.CDN_URL}/spriteoutput/relicfigures/IconRelic_${getRelic(relicSlotToDelete)?.relic_set_id}_${relicSlotToDelete}.png`}
alt="Relic"
unoptimized
crossOrigin="anonymous"
width={36}
height={36}
className="w-7 h-7 object-contain"
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold text-base-content truncate">
{getLocaleName(locale, mapRelicSet[getRelic(relicSlotToDelete)?.relic_set_id || ""]?.Name)}
</p>
<p className="text-[11px] text-base-content/60 font-mono">
+{getRelic(relicSlotToDelete)?.level} &bull; <span>{transI18n({ "1": "HEAD", "2": "HAND", "3": "BODY", "4": "FOOT", "5": "NECK", "6": "OBJECT" }[relicSlotToDelete] || relicSlotToDelete)}</span>
</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleCancelDelete}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-base-200 hover:bg-base-300 text-base-content/80 hover:text-base-content border border-white/10 transition-all cursor-pointer"
>
{transI18n("cancel")}
</button>
<button
type="button"
onClick={handleConfirmDelete}
className="flex-1 h-10 px-4 rounded-xl font-bold text-xs sm:text-sm bg-gradient-to-r from-rose-500 to-red-600 hover:from-rose-400 hover:to-red-500 text-white shadow-md shadow-rose-500/25 hover:shadow-rose-500/40 transition-all flex items-center justify-center gap-1.5 cursor-pointer"
>
<Trash2 className="w-4 h-4" />
<span>{transI18n("confirmDelete")}</span>
</button>
</div>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={handleCancelDelete}>close</button>
</form>
</dialog>
</div>
);
}
+17 -17
View File
@@ -48,7 +48,7 @@ export default function ShowCaseInfo() {
const handleSaveImage = useCallback(async () => {
if (cardRef.current === null || !avatarSelected) {
toast.error("Avatar showcase not found!");
toast.error(transI18n("noAvatarSelected") || "Avatar showcase not found!");
return;
}
if (isSaving) return;
@@ -78,7 +78,7 @@ export default function ShowCaseInfo() {
canvas.toBlob((blob) => {
if (!blob) {
toast.error("Failed to generate image!");
toast.error(transI18n("failedToGenerateImage"));
setIsSaving(false);
return;
}
@@ -89,10 +89,10 @@ export default function ShowCaseInfo() {
link.click();
URL.revokeObjectURL(url);
setIsSaving(false);
toast.success(locale === "vi" ? "Đã lưu ảnh thành công!" : "Image saved successfully!");
toast.success(transI18n("imageSavedSuccessfully"));
}, "image/png");
} catch {
toast.error("Error generating showcase card!");
toast.error(transI18n("errorGeneratingShowcaseCard"));
setIsSaving(false);
}
}, [avatarSelected, locale, transI18n, isSaving]);
@@ -638,16 +638,16 @@ export default function ShowCaseInfo() {
const textToCopy = lines.join("\n");
navigator.clipboard.writeText(textToCopy)
.then(() => {
toast.success(locale === "vi" ? "Đã sao chép thông tin build!" : "Build info copied to clipboard!");
toast.success(transI18n("buildInfoCopied"));
})
.catch(() => {
toast.error(locale === "vi" ? "Lỗi khi sao chép!" : "Failed to copy build info!");
toast.error(transI18n("copyFailed"));
});
}, [avatarSelected, avatarData, avatarProfile, mapLightCone, locale, transI18n, characterStats, relicEffects, mapRelicSet, relicStats]);
const handleCopyImage = useCallback(async () => {
if (cardRef.current === null || !avatarSelected) {
toast.error("Avatar showcase not found!");
toast.error(transI18n("noAvatarSelected") || "Avatar showcase not found!");
return;
}
if (isCopyingImage) return;
@@ -677,25 +677,25 @@ export default function ShowCaseInfo() {
canvas.toBlob((blob) => {
if (!blob) {
toast.error("Failed to create image blob");
toast.error(transI18n("failedToGenerateImage"));
setIsCopyingImage(false);
return;
}
navigator.clipboard.write([
new ClipboardItem({ "image/png": blob })
]).then(() => {
toast.success(locale === "vi" ? "Đã sao chép ảnh vào Clipboard!" : "Image copied to clipboard!");
toast.success(transI18n("imageCopiedToClipboard"));
}).catch(() => {
toast.error(locale === "vi" ? "Trình duyệt không cho phép sao chép ảnh trực tiếp!" : "Clipboard write failed!");
toast.error(transI18n("clipboardWriteFailed"));
}).finally(() => {
setIsCopyingImage(false);
});
}, "image/png");
} catch {
toast.error("Error generating showcase image!");
toast.error(transI18n("errorGeneratingShowcaseCard"));
setIsCopyingImage(false);
}
}, [avatarSelected, locale, isCopyingImage]);
}, [avatarSelected, isCopyingImage, transI18n]);
return (
<div className="w-full space-y-4">
@@ -712,23 +712,23 @@ export default function ShowCaseInfo() {
<button
className="btn btn-sm btn-ghost border border-base-content/20 rounded-xl font-bold shadow-xs active-press gap-1.5 hover:bg-base-200"
onClick={handleCopyBuildText}
title="Copy build summary as text"
title={transI18n("copyText")}
>
<Copy className="w-4 h-4" />
<span>{locale === "vi" ? "Sao chép Text" : "Copy Text"}</span>
<span>{transI18n("copyText")}</span>
</button>
<button
disabled={isCopyingImage}
className="btn btn-sm btn-ghost border border-base-content/20 rounded-xl font-bold shadow-xs active-press gap-1.5 hover:bg-base-200"
onClick={handleCopyImage}
title="Copy card image to clipboard"
title={transI18n("copyImage")}
>
{isCopyingImage ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<ImageIcon className="w-4 h-4" />
)}
<span>{isCopyingImage ? (locale === "vi" ? "Đang xử lý..." : "Processing...") : (locale === "vi" ? "Sao chép Ảnh" : "Copy Image")}</span>
<span>{isCopyingImage ? transI18n("processing") : transI18n("copyImage")}</span>
</button>
<button
disabled={isSaving}
@@ -740,7 +740,7 @@ export default function ShowCaseInfo() {
) : (
<Download className="w-4 h-4" />
)}
<span>{isSaving ? (locale === "vi" ? "Đang lưu..." : "Saving...") : transI18n("save")}</span>
<span>{isSaving ? transI18n("saving") : transI18n("save")}</span>
</button>
</div>
</div>