feat: initialize core components and multi-language support structures
Gitea Auto Deploy / Deploy-Container (push) Successful in 38s
Gitea Auto Deploy / Deploy-Container (push) Successful in 38s
This commit is contained in:
@@ -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">
|
||||
"{profileToDelete?.name}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
“{enkaData.detailInfo.signature}”
|
||||
</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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -401,7 +401,8 @@ export default function QuickView() {
|
||||
subAffix,
|
||||
relicEffects,
|
||||
mapRelicSet,
|
||||
avatarSkillTree
|
||||
avatarSkillTree,
|
||||
transI18n
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -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} • <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user