feat: implement custom theorycraft configuration and UI components for stage and lineup management
Gitea Auto Deploy / Deploy-Container (push) Successful in 39s

This commit is contained in:
2026-08-28 00:18:44 +07:00
parent 0514167aa8
commit bc8e0e2dc7
6 changed files with 920 additions and 523 deletions
+12 -9
View File
@@ -64,9 +64,9 @@ export default async function RootLayout({
const locale = await getLocale() const locale = await getLocale()
return ( return (
<html lang={locale} suppressHydrationWarning className="overflow-x-hidden"> <html lang={locale} suppressHydrationWarning className="h-full overflow-hidden">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-x-hidden max-w-full`} className={`${geistSans.variable} ${geistMono.variable} antialiased h-full overflow-hidden max-w-full`}
suppressHydrationWarning suppressHydrationWarning
> >
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
@@ -74,21 +74,24 @@ export default async function RootLayout({
<ThemeProvider> <ThemeProvider>
<ClientThemeWrapper> <ClientThemeWrapper>
<ClientDataFetcher > <ClientDataFetcher >
<div className="min-h-screen w-full flex flex-col justify-between bg-ambient-mesh selection:bg-primary/20 selection:text-primary"> <div className="h-screen w-full flex flex-col overflow-hidden bg-ambient-mesh selection:bg-primary/20 selection:text-primary">
<Header /> <Header />
<main className="grid grid-cols-12 w-full flex-1"> <main className="grid grid-cols-12 w-full flex-1 min-h-0 overflow-hidden">
<aside className="hidden sm:block sm:col-span-4 md:col-span-4 lg:col-span-3 sticky top-14 self-start h-[calc(100vh-3.5rem)] overflow-hidden border-r border-base-content/10 bg-base-100/30 backdrop-blur-sm"> <aside className="hidden sm:block sm:col-span-4 md:col-span-4 lg:col-span-3 h-full overflow-hidden border-r border-base-content/10 bg-base-100/30 backdrop-blur-sm">
<AvatarBar /> <AvatarBar />
</aside> </aside>
<section className="col-span-12 sm:col-span-8 md:col-span-8 lg:col-span-9 flex flex-col min-w-0"> <section className="col-span-12 sm:col-span-8 md:col-span-8 lg:col-span-9 h-full flex flex-col min-w-0 min-h-0 overflow-hidden">
<div className="shrink-0">
<ActionBar /> <ActionBar />
<div className="flex-1 p-2 sm:p-3 sm:pt-1"> </div>
<div className="flex-1 overflow-y-auto min-h-0 p-2 sm:p-3 sm:pt-1 flex flex-col justify-between">
<div>
{children} {children}
</div> </div>
<Footer />
</div>
</section> </section>
</main> </main>
<Footer />
</div> </div>
</ClientDataFetcher> </ClientDataFetcher>
</ClientThemeWrapper> </ClientThemeWrapper>
+338 -177
View File
@@ -1,5 +1,5 @@
'use client' 'use client'
import { EyeOff, Eye, Hammer, RefreshCw, ShieldBan, User, Swords, SkipForward, BowArrow, Info, RouteIcon, Search, CupSoda, UsersIcon } from "lucide-react" import { EyeOff, Eye, Hammer, RefreshCw, ShieldBan, User, Swords, SkipForward, BowArrow, Info, RouteIcon, Search, CupSoda, UsersIcon, X, Check, Code, Plus } from "lucide-react"
import useGlobalStore from '@/stores/globalStore' import useGlobalStore from '@/stores/globalStore'
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { getLocaleName, getNameChar } from "@/helper" import { getLocaleName, getNameChar } from "@/helper"
@@ -39,19 +39,19 @@ export default function ExtraSettingBar() {
) )
const [showSearchStage, setShowSearchStage] = useState(false) const [showSearchStage, setShowSearchStage] = useState(false)
const [showSearchLineup, setShowSearchLineup] = useState(false) const [showSearchLineup, setShowSearchLineup] = useState(false)
const [isChildClick, setIsChildClick] = useState(false)
const [stageSearchTerm, setStageSearchTerm] = useState("") const [stageSearchTerm, setStageSearchTerm] = useState("")
const [lineupSearchTerm, setLineupSearchTerm] = useState("")
const [stagePage, setStagePage] = useState(1) const [stagePage, setStagePage] = useState(1)
const pageSize = 30 const pageSize = 30
const stageList = useMemo(() => Object.values(stage), [stage]) const stageList = useMemo(() => Object.values(stage), [stage])
const term = stageSearchTerm.toLowerCase() const stageTerm = stageSearchTerm.toLowerCase()
const filteredStages = useMemo(() => stageList.filter((s) => const filteredStages = useMemo(() => stageList.filter((s) =>
getLocaleName(locale, s.Name).toLowerCase().includes(term) || getLocaleName(locale, s.Name).toLowerCase().includes(stageTerm) ||
String(s.ID).toLowerCase().includes(term) String(s.ID).toLowerCase().includes(stageTerm)
), [stageList, term, locale]) ), [stageList, stageTerm, locale])
const paginatedStages = useMemo(() => filteredStages.slice( const paginatedStages = useMemo(() => filteredStages.slice(
(stagePage - 1) * pageSize, (stagePage - 1) * pageSize,
@@ -60,35 +60,58 @@ export default function ExtraSettingBar() {
const hasMorePages = useMemo(() => stagePage * pageSize < filteredStages.length, [stagePage, filteredStages]) const hasMorePages = useMemo(() => stagePage * pageSize < filteredStages.length, [stagePage, filteredStages])
const onChangeSearch = (v: string) => { const onChangeStageSearch = (v: string) => {
setStageSearchTerm(v) setStageSearchTerm(v)
setStagePage(1) setStagePage(1)
} }
// Filter avatars for lineup selector
const avatarList = useMemo(() => Object.values(mapAvatar), [mapAvatar])
const filteredAvatars = useMemo(() => {
if (!lineupSearchTerm.trim()) return avatarList
const term = lineupSearchTerm.toLowerCase()
return avatarList.filter(a => {
const charName = getNameChar(locale, transI18n, a).toLowerCase()
return charName.includes(term) || a.ID.toString().includes(term)
})
}, [avatarList, lineupSearchTerm, locale, transI18n])
const currentLineup = extraData?.theory_craft?.custom_lineup || []
return ( return (
<div className="space-y-6"> <div className="w-full space-y-4 pb-2">
{/* THEORY CRAFT */} {/* THEORY CRAFT */}
{extraData?.theory_craft && ( {extraData?.theory_craft && (
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4"> <section className="relative z-30 rounded-2xl bg-base-200/40 border border-white/10 shadow-xs backdrop-blur-sm transition-all duration-200">
<div className="flex items-center justify-between pb-2 border-b border-base-content/10"> {/* Section Header */}
<h3 className="text-sm font-bold uppercase tracking-wider text-primary flex items-center gap-2"> <div className="flex items-center justify-between p-4 sm:p-5">
<span className="w-1.5 h-3.5 bg-primary rounded-full"></span> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 text-primary flex items-center justify-center shrink-0 shadow-inner">
<Hammer className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-bold tracking-tight text-base-content">
{transI18n("theoryCraft")} {transI18n("theoryCraft")}
</h3> </h3>
<div className="tooltip tooltip-left tooltip-info" data-tip={transI18n("detailTheoryCraft")}> <div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailTheoryCraft")}>
<Info className="text-primary w-4 h-4 cursor-pointer" /> <Info className="text-primary/70 hover:text-primary w-3.5 h-3.5 cursor-pointer transition-colors" />
</div>
</div>
<p className="text-xs text-base-content/60 line-clamp-1 mt-0.5">
{transI18n("detailTheoryCraft")}
</p>
</div> </div>
</div> </div>
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between"> <div className="flex items-center gap-2.5">
<label className="flex items-center gap-3 cursor-pointer"> <span className={`text-xs font-semibold uppercase tracking-wider hidden sm:inline ${extraData?.theory_craft?.mode ? 'text-primary' : 'text-base-content/40'}`}>
<Hammer className="text-primary w-4 h-4" /> {extraData?.theory_craft?.mode ? 'Active' : 'Off'}
<span className="text-xs sm:text-sm font-semibold">{transI18n("theoryCraftMode")}</span> </span>
</label>
<input <input
type="checkbox" type="checkbox"
className="toggle toggle-primary" className="toggle toggle-primary"
checked={extraData?.theory_craft?.mode} checked={extraData?.theory_craft?.mode || false}
onChange={(e) => { onChange={(e) => {
setExtraData({ setExtraData({
...extraData, ...extraData,
@@ -96,25 +119,31 @@ export default function ExtraSettingBar() {
hp: extraData?.theory_craft?.hp || {}, hp: extraData?.theory_craft?.hp || {},
cycle_count: extraData?.theory_craft?.cycle_count || 1, cycle_count: extraData?.theory_craft?.cycle_count || 1,
mode: e.target.checked, mode: e.target.checked,
stage_id: extraData?.theory_craft?.stage_id || 0 stage_id: extraData?.theory_craft?.stage_id || 0,
custom_lineup: extraData?.theory_craft?.custom_lineup || []
} }
}) })
}} }}
/> />
</div> </div>
{extraData?.theory_craft?.mode && (
<div className="space-y-3 pt-1">
{/* Cycle Count */}
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<RefreshCw className="text-info w-4 h-4" />
<span className="text-xs sm:text-sm font-semibold">{transI18n("cycleCount")}</span>
</div> </div>
{/* Theorycraft Expanded Content */}
{extraData?.theory_craft?.mode && (
<div className="border-t border-white/5 bg-base-300/20 p-4 sm:p-5 space-y-4">
{/* Simulation Settings Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Cycle Count */}
<div className="space-y-1.5">
<label className="flex items-center gap-2 text-xs font-semibold text-base-content/80">
<RefreshCw className="text-info w-3.5 h-3.5" />
<span>{transI18n("cycleCount")}</span>
</label>
<div className="relative">
<input <input
type="number" type="number"
className="input input-sm input-bordered w-24 rounded-xl text-center font-mono font-bold" className="input input-sm input-bordered w-full rounded-xl bg-base-100 text-base-content border-white/15 focus:border-primary text-xs sm:text-sm font-mono font-bold"
value={extraData?.theory_craft?.cycle_count} value={extraData?.theory_craft?.cycle_count || 1}
onChange={(e) => onChange={(e) =>
setExtraData({ setExtraData({
...extraData, ...extraData,
@@ -122,63 +151,75 @@ export default function ExtraSettingBar() {
hp: extraData?.theory_craft?.hp || {}, hp: extraData?.theory_craft?.hp || {},
cycle_count: Number(e.target.value) || 1, cycle_count: Number(e.target.value) || 1,
mode: extraData?.theory_craft?.mode || false, mode: extraData?.theory_craft?.mode || false,
stage_id: extraData?.theory_craft?.stage_id || 0 stage_id: extraData?.theory_craft?.stage_id || 0,
custom_lineup: extraData?.theory_craft?.custom_lineup || []
} }
}) })
} }
min="1" min="1"
/> />
</div> </div>
</div>
{/* Stage Selection */} {/* Stage Selection */}
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2"> <div className="space-y-1.5 relative">
<div className="flex items-center gap-2"> <label className="flex items-center gap-2 text-xs font-semibold text-base-content/80">
<RouteIcon className="text-info w-4 h-4" /> <RouteIcon className="text-info w-3.5 h-3.5" />
<span className="text-xs sm:text-sm font-semibold">{transI18n("stage")}</span> <span>{transI18n("stage")}</span>
</div> </label>
<div className="relative">
<button <button
className="btn btn-sm btn-outline w-full rounded-xl flex items-center justify-between text-xs sm:text-sm" type="button"
onClick={(e) => { className="btn btn-sm btn-outline w-full rounded-xl bg-base-100 text-base-content border-white/15 hover:border-primary flex items-center justify-between text-xs font-normal transition-all"
e.stopPropagation() onClick={() => setShowSearchStage(pre => !pre)}
if (!isChildClick) {
setShowSearchStage(pre => !pre)
}
setIsChildClick(false)
}}
> >
<span className="truncate"> <span className="truncate">
{getLocaleName(locale, stageList.find((s) => s.ID.toString() === extraData?.theory_craft?.stage_id?.toString())?.Name) || transI18n("selectStage")} {getLocaleName(locale, stageList.find((s) => s.ID.toString() === extraData?.theory_craft?.stage_id?.toString())?.Name) || transI18n("selectStage")}
</span> </span>
<Search className="w-3.5 h-3.5 shrink-0 ml-2" /> <Search className="w-3.5 h-3.5 shrink-0 text-base-content/50 ml-2" />
</button> </button>
{/* Stage Search Popover */}
{showSearchStage && ( {showSearchStage && (
<div onClick={(e) => e.stopPropagation()} className="absolute top-full mt-2 w-full z-50 bg-base-100 border border-base-content/15 rounded-2xl p-3.5 shadow-2xl space-y-3"> <div
onClick={(e) => e.stopPropagation()}
className="absolute top-full mt-2 left-0 right-0 z-50 bg-base-100 border border-white/15 rounded-2xl p-3 shadow-2xl space-y-2.5"
>
<div className="relative">
<input <input
type="search" type="search"
className="input input-sm input-bordered w-full rounded-xl text-xs" className="input input-sm input-bordered w-full rounded-xl bg-base-200/50 border-white/10 text-xs pr-7 focus:border-primary"
placeholder={transI18n("searchStage")} placeholder={transI18n("searchStage")}
value={stageSearchTerm} value={stageSearchTerm}
onChange={(e) => onChangeSearch(e.target.value)} onChange={(e) => onChangeStageSearch(e.target.value)}
autoFocus autoFocus
/> />
{stageSearchTerm && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content"
onClick={() => onChangeStageSearch("")}
>
<X className="w-3 h-3" />
</button>
)}
</div>
<div className="max-h-56 overflow-y-auto space-y-1 pr-1 text-xs"> <div className="max-h-52 overflow-y-auto space-y-0.5 pr-1 text-xs divide-y divide-white/5">
{paginatedStages.length > 0 ? ( {paginatedStages.length > 0 ? (
paginatedStages.map((stage) => ( paginatedStages.map((stg) => {
const isSelected = extraData?.theory_craft?.stage_id === Number(stg.ID)
return (
<div <div
key={stage.ID} key={stg.ID}
className="p-2 hover:bg-primary/20 rounded-xl cursor-pointer transition-colors" className={`p-2 rounded-lg cursor-pointer flex items-center justify-between transition-colors ${
onClick={(e) => { isSelected ? "bg-primary/20 text-primary font-bold" : "hover:bg-base-200/80 text-base-content/80 hover:text-base-content"
e.stopPropagation() }`}
setIsChildClick(true) onClick={() => {
if (extraData?.theory_craft?.stage_id !== Number(stg.ID)) {
if (extraData?.theory_craft?.stage_id !== Number(stage.ID)) {
setExtraData({ setExtraData({
...extraData, ...extraData,
theory_craft: { theory_craft: {
stage_id: Number(stage.ID), stage_id: Number(stg.ID),
cycle_count: extraData?.theory_craft?.cycle_count ?? 1, cycle_count: extraData?.theory_craft?.cycle_count ?? 1,
mode: extraData?.theory_craft?.mode ?? false, mode: extraData?.theory_craft?.mode ?? false,
hp: extraData?.theory_craft?.hp ?? {}, hp: extraData?.theory_craft?.hp ?? {},
@@ -186,38 +227,34 @@ export default function ExtraSettingBar() {
} }
}) })
} }
setShowSearchStage(pre => !pre) setShowSearchStage(false)
onChangeSearch("") onChangeStageSearch("")
}} }}
> >
{getLocaleName(locale, stage.Name)} <span className="truncate">{getLocaleName(locale, stg.Name)}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0 ml-1.5" />}
</div> </div>
)) )
})
) : ( ) : (
<div className="text-xs text-center py-4 text-base-content/40">{transI18n("noStageFound")}</div> <div className="text-xs text-center py-4 text-base-content/40">{transI18n("noStageFound")}</div>
)} )}
</div> </div>
{paginatedStages.length > 0 && ( {paginatedStages.length > 0 && (
<div className="flex items-center justify-between gap-2 pt-2 border-t border-white/5"> <div className="flex items-center justify-between gap-2 pt-2 border-t border-white/5 text-[11px]">
<button <button
disabled={stagePage === 1} disabled={stagePage === 1}
className="btn btn-xs btn-outline rounded-lg" className="btn btn-xs btn-outline rounded-lg"
onClick={(e) => { onClick={() => setStagePage(stagePage - 1)}
e.stopPropagation()
setStagePage(stagePage - 1)
}}
> >
{transI18n("previous")} {transI18n("previous")}
</button> </button>
<span className="text-[11px] font-mono text-base-content/50">Page {stagePage}</span> <span className="font-mono text-base-content/50">Page {stagePage}</span>
<button <button
disabled={!hasMorePages} disabled={!hasMorePages}
className="btn btn-xs btn-outline rounded-lg" className="btn btn-xs btn-outline rounded-lg"
onClick={(e) => { onClick={() => setStagePage(stagePage + 1)}
e.stopPropagation()
setStagePage(stagePage + 1)
}}
> >
{transI18n("next")} {transI18n("next")}
</button> </button>
@@ -229,23 +266,47 @@ export default function ExtraSettingBar() {
</div> </div>
{/* Custom Lineup */} {/* Custom Lineup */}
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2.5"> <div className="space-y-3 pt-2 border-t border-white/5">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<UsersIcon className="text-info w-4 h-4" /> <label className="flex items-center gap-2 text-xs font-semibold text-base-content/80">
<span className="text-xs sm:text-sm font-semibold">{transI18n("customLineup")}</span> <UsersIcon className="text-info w-3.5 h-3.5" />
<span>{transI18n("customLineup")}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20 font-bold">
{currentLineup.length}
</span>
</label>
<button
type="button"
className="btn btn-xs btn-ghost text-primary hover:bg-primary/10 gap-1 rounded-lg"
onClick={() => setShowSearchLineup(pre => !pre)}
>
{showSearchLineup ? (
<>
<X className="w-3 h-3" />
<span>Close List</span>
</>
) : (
<>
<Plus className="w-3 h-3" />
<span>Select Characters</span>
</>
)}
</button>
</div> </div>
<div className="flex flex-wrap items-center gap-2 min-h-16 p-2 bg-base-200/50 rounded-xl border border-white/5"> {/* Selected Avatars Strip */}
{(extraData?.theory_craft?.custom_lineup || []).map((avatarId) => { <div className="flex flex-wrap items-center gap-2">
const avatarData = mapAvatar[avatarId]; {currentLineup.map((avatarId) => {
if (!avatarData) return null; const avatarData = mapAvatar[avatarId]
if (!avatarData) return null
return ( return (
<SimpleAvatarCard <SimpleAvatarCard
key={`selected-${avatarId}`} key={`selected-${avatarId}`}
data={avatarData} data={avatarData}
showRemoveHover={true} showRemoveHover={true}
onClick={() => { onClick={() => {
const newLineup = (extraData?.theory_craft?.custom_lineup || []).filter(id => id !== avatarId); const newLineup = currentLineup.filter(id => id !== avatarId)
setExtraData({ setExtraData({
...extraData, ...extraData,
theory_craft: { theory_craft: {
@@ -255,51 +316,56 @@ export default function ExtraSettingBar() {
hp: extraData?.theory_craft?.hp ?? {}, hp: extraData?.theory_craft?.hp ?? {},
custom_lineup: newLineup custom_lineup: newLineup
} }
}); })
}} }}
/> />
); )
})} })}
{!showSearchLineup && (
<button <button
className="btn btn-outline btn-square border-dashed w-14 h-14 sm:w-16 sm:h-16 rounded-xl" type="button"
onClick={(e) => { className="btn btn-outline btn-square border-dashed border-white/20 hover:border-primary/50 hover:bg-primary/10 w-16 h-16 sm:w-20 sm:h-20 rounded-xl flex flex-col items-center justify-center gap-1 text-base-content/50 hover:text-primary transition-all"
e.stopPropagation(); onClick={() => setShowSearchLineup(true)}
setShowSearchLineup(pre => !pre); title="Add Character"
}}
> >
+ <Plus className="w-5 h-5" />
<span className="text-[9px] uppercase font-bold tracking-wider">Add</span>
</button> </button>
)}
</div> </div>
{/* Inline Character Selection Pool */}
{showSearchLineup && ( {showSearchLineup && (
<div <div className="space-y-2.5 pt-2">
onClick={(e) => e.stopPropagation()} <div className="relative">
className="mt-2 w-full z-50 bg-base-100 border border-base-content/15 rounded-2xl p-4 shadow-2xl space-y-3" <Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40" />
> <input
<div className="flex justify-between items-center pb-2 border-b border-base-content/10"> type="search"
<span className="font-bold text-xs">{transI18n("selectedCharacters")}</span> className="input input-sm input-bordered w-full rounded-xl bg-base-100 text-base-content border-white/10 pl-8 text-xs focus:border-primary"
<button className="btn btn-xs btn-circle btn-ghost" onClick={() => setShowSearchLineup(false)}>✕</button> placeholder="Search character..."
value={lineupSearchTerm}
onChange={(e) => setLineupSearchTerm(e.target.value)}
autoFocus
/>
</div> </div>
<div className="flex flex-wrap gap-2 max-h-60 overflow-y-auto p-1"> <div className="flex flex-wrap gap-2 max-h-56 overflow-y-auto pr-1">
{Object.values(mapAvatar).map((avatar) => { {filteredAvatars.map((avatar) => {
const currentLineup = extraData?.theory_craft?.custom_lineup || []; const isSelected = currentLineup.includes(avatar.ID.toString())
const isSelected = currentLineup.includes(avatar.ID.toString());
return ( return (
<SimpleAvatarCard <SimpleAvatarCard
key={avatar.ID} key={avatar.ID}
data={avatar} data={avatar}
isSelected={isSelected} isSelected={isSelected}
onClick={() => { onClick={() => {
const idStr = avatar.ID.toString(); const idStr = avatar.ID.toString()
let newLineup = [...currentLineup]; let newLineup = [...currentLineup]
if (isSelected) { if (isSelected) {
newLineup = newLineup.filter(id => id !== idStr); newLineup = newLineup.filter(id => id !== idStr)
} else { } else {
newLineup.push(idStr); newLineup.push(idStr)
} }
setExtraData({ setExtraData({
@@ -311,10 +377,10 @@ export default function ExtraSettingBar() {
hp: extraData?.theory_craft?.hp ?? {}, hp: extraData?.theory_craft?.hp ?? {},
custom_lineup: newLineup custom_lineup: newLineup
} }
}); })
}} }}
/> />
); )
})} })}
</div> </div>
</div> </div>
@@ -322,35 +388,53 @@ export default function ExtraSettingBar() {
</div> </div>
</div> </div>
)} )}
</div> </section>
)} )}
{/* MULTIPATH CHAR */} {/* MULTIPATH CHARACTER */}
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4"> <section className="relative z-20 rounded-2xl bg-base-200/40 border border-white/10 shadow-xs backdrop-blur-sm transition-all duration-200">
<div className="flex items-center justify-between pb-2 border-b border-base-content/10"> <div className="flex items-center justify-between p-4 sm:p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-secondary/10 border border-secondary/20 text-secondary flex items-center justify-center shrink-0 shadow-inner">
<RouteIcon className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-bold uppercase tracking-wider text-primary"> <h3 className="text-sm font-bold tracking-tight text-base-content">
{transI18n("multipathCharacter")} {transI18n("multipathCharacter")}
</h3> </h3>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailMultipathCharacter")}> <div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailMultipathCharacter")}>
<Info className="text-primary w-4 h-4 cursor-pointer" /> <Info className="text-primary/70 hover:text-primary w-3.5 h-3.5 cursor-pointer transition-colors" />
</div> </div>
</div> </div>
<p className="text-xs text-base-content/60 line-clamp-1 mt-0.5">
{transI18n("detailMultipathCharacter")}
</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-xs font-semibold uppercase tracking-wider hidden sm:inline ${isEnableChangePath ? 'text-secondary' : 'text-base-content/40'}`}>
{isEnableChangePath ? 'Active' : 'Off'}
</span>
<input <input
type="checkbox" type="checkbox"
className="toggle toggle-primary" className="toggle toggle-secondary"
checked={isEnableChangePath} checked={isEnableChangePath}
onChange={(e) => setIsEnableChangePath(e.target.checked)} onChange={(e) => setIsEnableChangePath(e.target.checked)}
/> />
</div> </div>
</div>
{isEnableChangePath && ( {isEnableChangePath && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1"> <div className="border-t border-white/5 bg-base-300/20 p-4 sm:p-5">
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center gap-2"> {/* Main Character Path */}
<User className="text-warning w-4 h-4" /> <div className="space-y-1.5">
<span className="text-xs font-semibold">{transI18n("mainPath")}</span> <label className="flex items-center gap-2 text-xs font-semibold text-base-content/80">
</div> <User className="text-warning w-3.5 h-3.5" />
<span>{transI18n("mainPath")}</span>
</label>
<SelectCustomImage <SelectCustomImage
customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_main?.includes(Number(it.ID))).map((it) => ({ customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_main?.includes(Number(it.ID))).map((it) => ({
value: it.ID.toString(), value: it.ID.toString(),
@@ -376,11 +460,12 @@ export default function ExtraSettingBar() {
/> />
</div> </div>
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2"> {/* March 7th Path */}
<div className="flex items-center gap-2"> <div className="space-y-1.5">
<BowArrow className="text-info w-4 h-4" /> <label className="flex items-center gap-2 text-xs font-semibold text-base-content/80">
<span className="text-xs font-semibold">{transI18n("march7Path")}</span> <BowArrow className="text-info w-3.5 h-3.5" />
</div> <span>{transI18n("march7Path")}</span>
</label>
<SelectCustomImage <SelectCustomImage
customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_march_7?.includes(Number(it.ID))).map((it) => ({ customSet={Object.values(mapAvatar).filter((it) => extraData?.multi_path?.multi_path_march_7?.includes(Number(it.ID))).map((it) => ({
value: it.ID.toString(), value: it.ID.toString(),
@@ -406,31 +491,42 @@ export default function ExtraSettingBar() {
/> />
</div> </div>
</div> </div>
)}
</div> </div>
)}
</section>
{/* CHALLENGE */} {/* CHALLENGE SETTINGS */}
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4"> <section className="relative z-10 rounded-2xl bg-base-200/40 border border-white/10 shadow-xs backdrop-blur-sm">
<div className="pb-2 border-b border-base-content/10"> <div className="p-4 sm:p-5 border-b border-white/5 flex items-center gap-3">
<h3 className="text-sm font-bold uppercase tracking-wider text-primary"> <div className="w-10 h-10 rounded-xl bg-error/10 border border-error/20 text-error flex items-center justify-center shrink-0 shadow-inner">
<Swords className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold tracking-tight text-base-content">
{transI18n("challenge")} {transI18n("challenge")}
</h3> </h3>
<p className="text-xs text-base-content/60 line-clamp-1 mt-0.5">
Configure anomaly peak mode and node skip settings
</p>
</div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="p-4 sm:p-5">
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Anomaly Arbitration */}
<div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <label className="flex items-center gap-2 text-xs font-semibold text-base-content/80">
<Swords className="text-error w-4 h-4" /> <Swords className="text-error w-3.5 h-3.5" />
<span className="text-xs font-semibold">{transI18n("anomalyArbitration")}</span> <span>{transI18n("anomalyArbitration")}</span>
</div> </label>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailChallengePeak")}> <div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailChallengePeak")}>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" /> <Info className="text-primary/70 hover:text-primary w-3.5 h-3.5 cursor-pointer transition-colors" />
</div> </div>
</div> </div>
<select <select
value={extraData?.challenge?.challenge_peak_group_id || ""} value={extraData?.challenge?.challenge_peak_group_id || ""}
className="select select-sm select-bordered w-full rounded-xl text-xs" className="select select-sm select-bordered w-full rounded-xl bg-base-100 text-base-content border-white/15 focus:border-primary text-xs font-medium"
onChange={(e) => onChange={(e) =>
setExtraData({ setExtraData({
...extraData, ...extraData,
@@ -444,26 +540,27 @@ export default function ExtraSettingBar() {
} }
> >
{Object.values(mapPeak).filter(event => extraData?.challenge?.challenge_peak_group_id_list?.includes(Number(event.ID))).map(event => ( {Object.values(mapPeak).filter(event => extraData?.challenge?.challenge_peak_group_id_list?.includes(Number(event.ID))).map(event => (
<option key={event.ID} value={event.ID}> <option key={event.ID} value={event.ID} className="bg-base-100 text-base-content py-2">
{getLocaleName(locale, event.Name)} ({event.ID}) {getLocaleName(locale, event.Name)} ({event.ID})
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 space-y-2"> {/* Skip Node */}
<div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <label className="flex items-center gap-2 text-xs font-semibold text-base-content/80">
<SkipForward className="text-warning w-4 h-4" /> <SkipForward className="text-warning w-3.5 h-3.5" />
<span className="text-xs font-semibold">{transI18n("skipNode")}</span> <span>{transI18n("skipNode")}</span>
</div> </label>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailSkipNode")}> <div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailSkipNode")}>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" /> <Info className="text-primary/70 hover:text-primary w-3.5 h-3.5 cursor-pointer transition-colors" />
</div> </div>
</div> </div>
<select <select
value={extraData?.challenge?.skip_node || "0"} value={extraData?.challenge?.skip_node || "0"}
className="select select-sm select-bordered w-full rounded-xl text-xs" className="select select-sm select-bordered w-full rounded-xl bg-base-100 text-base-content border-white/15 focus:border-primary text-xs font-medium"
onChange={(e) => onChange={(e) =>
setExtraData({ setExtraData({
...extraData, ...extraData,
@@ -476,33 +573,52 @@ export default function ExtraSettingBar() {
}) })
} }
> >
<option value="0">{transI18n("disableSkip")}</option> <option value="0" className="bg-base-100 text-base-content py-2">{transI18n("disableSkip")}</option>
<option value="1">{transI18n("skipNode1")}</option> <option value="1" className="bg-base-100 text-base-content py-2">{transI18n("skipNode1")}</option>
<option value="2">{transI18n("skipNode2")}</option> <option value="2" className="bg-base-100 text-base-content py-2">{transI18n("skipNode2")}</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
</section>
{/* EXTRA FEATURES */} {/* EXTRA FEATURES */}
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4"> <section className="relative z-0 rounded-2xl bg-base-200/40 border border-white/10 shadow-xs backdrop-blur-sm">
<div className="pb-2 border-b border-base-content/10"> <div className="p-4 sm:p-5 border-b border-white/5 flex items-center gap-3">
<h3 className="text-sm font-bold uppercase tracking-wider text-primary"> <div className="w-10 h-10 rounded-xl bg-accent/10 border border-accent/20 text-accent flex items-center justify-center shrink-0 shadow-inner">
<Eye className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold tracking-tight text-base-content">
{transI18n("extraFeatures")} {transI18n("extraFeatures")}
</h3> </h3>
<p className="text-xs text-base-content/60 line-clamp-1 mt-0.5">
UI display overrides and visual preferences
</p>
</div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="p-2 sm:p-3 divide-y divide-white/5">
{/* Hide UI Row */}
{extraData?.setting?.hide_ui !== undefined && ( {extraData?.setting?.hide_ui !== undefined && (
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between"> <div className="flex items-center justify-between p-3 rounded-xl hover:bg-white/[0.03] transition-colors">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-base-100/80 border border-white/10 flex items-center justify-center">
{extraData?.setting?.hide_ui {extraData?.setting?.hide_ui
? <EyeOff className="text-warning w-4 h-4" /> ? <EyeOff className="text-warning w-4 h-4" />
: <Eye className="text-success w-4 h-4" /> : <Eye className="text-success w-4 h-4" />
} }
<span className="text-xs font-semibold">{transI18n("hideUI")}</span> </div>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailHiddenUi")}> <div>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" /> <div className="flex items-center gap-1.5">
<span className="text-xs sm:text-sm font-semibold text-base-content">{transI18n("hideUI")}</span>
<div className="tooltip tooltip-right tooltip-info" data-tip={transI18n("detailHiddenUi")}>
<Info className="text-primary/70 hover:text-primary w-3 h-3 cursor-pointer" />
</div>
</div>
<p className="text-[11px] text-base-content/50 hidden sm:block">
{transI18n("detailHiddenUi")}
</p>
</div> </div>
</div> </div>
@@ -525,13 +641,23 @@ export default function ExtraSettingBar() {
</div> </div>
)} )}
{/* Disable Censorship Row */}
{extraData?.setting?.censorship !== undefined && ( {extraData?.setting?.censorship !== undefined && (
<div className="p-3.5 rounded-xl bg-base-100/60 border border-white/5 flex items-center justify-between"> <div className="flex items-center justify-between p-3 rounded-xl hover:bg-white/[0.03] transition-colors">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-base-100/80 border border-white/10 flex items-center justify-center">
<ShieldBan className="text-error w-4 h-4" /> <ShieldBan className="text-error w-4 h-4" />
<span className="text-xs font-semibold">{transI18n("disableCensorship")}</span> </div>
<div className="tooltip tooltip-bottom tooltip-info" data-tip={transI18n("detailDisableCensorship")}> <div>
<Info className="text-primary w-3.5 h-3.5 cursor-pointer" /> <div className="flex items-center gap-1.5">
<span className="text-xs sm:text-sm font-semibold text-base-content">{transI18n("disableCensorship")}</span>
<div className="tooltip tooltip-right tooltip-info" data-tip={transI18n("detailDisableCensorship")}>
<Info className="text-primary/70 hover:text-primary w-3 h-3 cursor-pointer" />
</div>
</div>
<p className="text-[11px] text-base-content/50 hidden sm:block">
{transI18n("detailDisableCensorship")}
</p>
</div> </div>
</div> </div>
@@ -554,25 +680,60 @@ export default function ExtraSettingBar() {
</div> </div>
)} )}
</div> </div>
</section>
{/* LUA SCRIPTING */}
<section className="relative rounded-2xl bg-base-200/40 border border-white/10 shadow-xs backdrop-blur-sm transition-all duration-200">
<div className="flex items-center justify-between p-4 sm:p-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-400/10 border border-amber-400/20 text-amber-400 flex items-center justify-center shrink-0 shadow-inner">
<CupSoda className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold tracking-tight text-base-content">
Lua Scripting
</h3>
<p className="text-xs text-base-content/60 line-clamp-1 mt-0.5">
Inject custom Lua runtime scripts and behaviors
</p>
</div>
</div> </div>
{/* LUA EDITOR */} <div className="flex items-center gap-2.5">
<div className="p-4 sm:p-5 rounded-2xl bg-base-200/60 border border-white/10 shadow-lg space-y-4"> <span className={`text-xs font-semibold uppercase tracking-wider hidden sm:inline ${isEnableLua ? 'text-amber-400' : 'text-base-content/40'}`}>
<div className="flex items-center justify-between pb-2 border-b border-base-content/10"> {isEnableLua ? 'Active' : 'Off'}
<div className="flex items-center gap-2"> </span>
<CupSoda className="text-primary w-4 h-4" />
<h3 className="text-sm font-bold uppercase tracking-wider text-primary">Lua Scripting</h3>
</div>
<input <input
type="checkbox" type="checkbox"
className="toggle toggle-primary" className="toggle toggle-warning"
checked={isEnableLua} checked={isEnableLua}
onChange={(e) => setIsEnableLua(e.target.checked)} onChange={(e) => setIsEnableLua(e.target.checked)}
/> />
</div> </div>
</div>
{isEnableLua && ( {isEnableLua && (
<div className="rounded-2xl overflow-hidden border border-white/10 shadow-inner"> <div className="border-t border-white/5 bg-[#0d0e15] overflow-hidden">
{/* Terminal Window Header Bar */}
<div className="flex items-center justify-between px-4 py-2.5 bg-[#141622] border-b border-white/5">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-rose-500/70"></div>
<div className="w-2.5 h-2.5 rounded-full bg-amber-500/70"></div>
<div className="w-2.5 h-2.5 rounded-full bg-emerald-500/70"></div>
</div>
<div className="h-3.5 w-px bg-white/10 mx-1.5"></div>
<div className="flex items-center gap-1.5 text-xs text-base-content/70 font-mono">
<Code className="w-3.5 h-3.5 text-amber-400" />
<span>script.lua</span>
</div>
</div>
<span className="text-[10px] uppercase font-mono px-2 py-0.5 rounded-md bg-white/5 text-base-content/50 border border-white/5">
LUA 5.4
</span>
</div>
{/* Code Editor */}
<Editor <Editor
value={extraData?.lua || ""} value={extraData?.lua || ""}
onValueChange={(code) => onValueChange={(code) =>
@@ -584,15 +745,15 @@ export default function ExtraSettingBar() {
highlight={(code) => Prism.highlight(code, Prism.languages.lua, "lua")} highlight={(code) => Prism.highlight(code, Prism.languages.lua, "lua")}
padding={16} padding={16}
textareaClassName="outline-none font-mono" textareaClassName="outline-none font-mono"
className="text-xs font-mono min-h-60 bg-[#161822] text-white" className="text-xs font-mono min-h-60 text-slate-100 bg-[#0d0e15]"
style={{ style={{
background: "#161822", background: "#0d0e15",
color: "#e2e8f0", color: "#e2e8f0",
}} }}
/> />
</div> </div>
)} )}
</div> </section>
</div> </div>
) )
} }
+9 -1
View File
@@ -636,7 +636,15 @@ export default function Header() {
{/* Modals */} {/* Modals */}
{modalConfigs?.map(({ id, title, onClose, content }) => ( {modalConfigs?.map(({ id, title, onClose, content }) => (
<dialog key={id} id={id} className="modal"> <dialog key={id} id={id} className="modal">
<div className={`modal-box w-11/12 max-w-[92vw] sm:max-w-[88vw] ${id === 'monster_modal' ? 'h-[85vh]' : '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`}> <div className={`modal-box w-11/12 ${
id === 'monster_modal' || id === 'import_modal' || id === 'extra_modal'
? 'max-w-[95vw] sm:max-w-[90vw] lg:max-w-[85vw] 2xl:max-w-[1500px] h-[88vh]'
: id === 'connect_modal'
? 'max-w-[95vw] sm:max-w-2xl md:max-w-3xl lg:max-w-4xl max-h-[88vh]'
: id === 'changelog_modal'
? 'max-w-[95vw] sm:max-w-2xl md:max-w-3xl lg:max-w-4xl h-[85vh]'
: '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 */} {/* 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"> <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"> <h3 className="font-bold text-xl tracking-tight bg-clip-text text-transparent bg-linear-to-r from-primary via-accent to-secondary">
+2 -4
View File
@@ -167,8 +167,7 @@ export default function EnkaImport() {
}; };
return ( return (
<div className="bg-base-100 p-4 sm:p-6 rounded-2xl"> <div className="w-full space-y-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">HSR UID</h1> <h1 className="text-base-content text-xl sm:text-2xl font-bold">HSR UID</h1>
@@ -235,7 +234,7 @@ export default function EnkaImport() {
</div> </div>
)} )}
{/* Character Grid */} {/* Character Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <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) => ( {validCharacters.map((character) => (
<CharacterInfoCard <CharacterInfoCard
key={character.avatarId} key={character.avatarId}
@@ -246,6 +245,5 @@ export default function EnkaImport() {
))} ))}
</div> </div>
</div> </div>
</div>
); );
} }
+2 -4
View File
@@ -189,8 +189,7 @@ export default function FreeSRImport() {
} }
return ( return (
<div className="bg-base-100 p-4 sm:p-6 rounded-2xl"> <div className="w-full space-y-6">
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */} {/* Header */}
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-base-content text-xl sm:text-2xl font-bold">{transI18n("freeSRImport")}</h1> <h1 className="text-base-content text-xl sm:text-2xl font-bold">{transI18n("freeSRImport")}</h1>
@@ -244,7 +243,7 @@ export default function FreeSRImport() {
</div> </div>
)} )}
{/* Character Grid */} {/* Character Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <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) => { {validFreeSRData && Object.values(validFreeSRData.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
return ( return (
<CharacterInfoCard <CharacterInfoCard
@@ -257,6 +256,5 @@ export default function FreeSRImport() {
})} })}
</div> </div>
</div> </div>
</div>
) )
} }
+247 -18
View File
@@ -15,6 +15,7 @@ import useDetailDataStore from '@/stores/detailDataStore';
import useCurrentDataStore from '@/stores/currentDataStore'; import useCurrentDataStore from '@/stores/currentDataStore';
import { getLocaleName } from '@/helper'; import { getLocaleName } from '@/helper';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { Copy, Download, Image as ImageIcon } from 'lucide-react';
export default function ShowCaseInfo() { export default function ShowCaseInfo() {
@@ -36,6 +37,8 @@ export default function ShowCaseInfo() {
useShallow((state) => ({ avatars: state.avatars })) useShallow((state) => ({ avatars: state.avatars }))
) )
const [avgColor, setAvgColor] = useState('#222'); const [avgColor, setAvgColor] = useState('#222');
const [isSaving, setIsSaving] = useState(false);
const [isCopyingImage, setIsCopyingImage] = useState(false);
const imgRef = useRef(null); const imgRef = useRef(null);
const cardRef = useRef(null) const cardRef = useRef(null)
const { locale } = useLocaleStore( const { locale } = useLocaleStore(
@@ -43,31 +46,56 @@ export default function ShowCaseInfo() {
) )
const transI18n = useTranslations("DataPage") const transI18n = useTranslations("DataPage")
const handleSaveImage = useCallback(() => { const handleSaveImage = useCallback(async () => {
if (cardRef.current === null || !avatarSelected) { if (cardRef.current === null || !avatarSelected) {
toast.error("Avatar showcase not found!"); toast.error("Avatar showcase not found!");
return; return;
} }
if (isSaving) return;
import("html2canvas-pro") setIsSaving(true);
.then(({ default: html2canvas }) => try {
html2canvas(cardRef.current!, { const { default: html2canvas } = await import("html2canvas-pro");
const canvas = await html2canvas(cardRef.current!, {
scale: 2, scale: 2,
backgroundColor: "#000000", backgroundColor: "#000000",
allowTaint: false, allowTaint: false,
useCORS: true useCORS: true,
}) logging: false,
) width: 1600,
.then((canvas: HTMLCanvasElement) => { height: 650,
windowWidth: 1600,
windowHeight: 650,
onclone: (clonedDoc) => {
const clonedCard = clonedDoc.querySelector('[data-showcase-card="true"]') as HTMLElement | null;
if (clonedCard) {
clonedCard.style.transform = "none";
clonedCard.style.width = "1600px";
clonedCard.style.minHeight = "650px";
}
}
});
canvas.toBlob((blob) => {
if (!blob) {
toast.error("Failed to generate image!");
setIsSaving(false);
return;
}
const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.download = `${getNameChar(locale, transI18n, avatarSelected)}_showcase.png`; link.download = `${getNameChar(locale, transI18n, avatarSelected)}_showcase.png`;
link.href = canvas.toDataURL("image/png"); link.href = url;
link.click(); link.click();
}) URL.revokeObjectURL(url);
.catch(() => { setIsSaving(false);
toast.success(locale === "vi" ? "Đã lưu ảnh thành công!" : "Image saved successfully!");
}, "image/png");
} catch {
toast.error("Error generating showcase card!"); toast.error("Error generating showcase card!");
}); setIsSaving(false);
}, [avatarSelected, locale, transI18n]); }
}, [avatarSelected, locale, transI18n, isSaving]);
useEffect(() => { useEffect(() => {
if (!avatarSelected) return; if (!avatarSelected) return;
@@ -512,6 +540,163 @@ export default function ShowCaseInfo() {
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}, []) }, [])
const wrapperRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
useEffect(() => {
if (!wrapperRef.current) return;
const updateScale = () => {
if (wrapperRef.current) {
const containerWidth = wrapperRef.current.clientWidth;
const baseWidth = 1600;
const newScale = Math.min(1, containerWidth / baseWidth);
setScale(newScale);
}
};
updateScale();
const resizeObserver = new ResizeObserver(updateScale);
resizeObserver.observe(wrapperRef.current);
return () => resizeObserver.disconnect();
}, []);
const handleCopyBuildText = useCallback(() => {
if (!avatarSelected || !avatarData) {
toast.error("Avatar not found!");
return;
}
const charName = getNameChar(locale, transI18n, avatarSelected);
const charLevel = `Lv. ${avatarData.level}/80`;
const eidolonRank = `E${avatarData.data?.rank ?? 0}`;
// Light cone info
let lcText = "None";
if (avatarProfile?.lightcone && mapLightCone?.[avatarProfile.lightcone.item_id]) {
const lcData = mapLightCone[avatarProfile.lightcone.item_id];
const lcName = getLocaleName(locale, lcData.Name) || lcData.Name?.en || "Light Cone";
const lcRank = `S${avatarProfile.lightcone.rank || 1}`;
const lcLevel = `Lv. ${avatarProfile.lightcone.level || 80}/80`;
lcText = `${lcName} (${lcRank}, ${lcLevel})`;
}
// Stats summary
const statsList: string[] = [];
if (characterStats) {
const mainStatKeys = ["HP", "ATK", "DEF", "SPD", "CRITRate", "CRITDmg", "BreakEffect", "EnergyRate", "EffectHitRate", "EffectRES"];
for (const key of mainStatKeys) {
const stat = characterStats[key];
if (stat) {
const valFormatted = stat.round === 0
? Math.round(stat.value).toLocaleString()
: stat.value.toFixed(stat.round);
statsList.push(`• ${stat.name}: ${valFormatted}${stat.unit}`);
}
}
}
// Relic sets
const relicSetList: string[] = [];
if (relicEffects && relicEffects.length > 0) {
for (const effect of relicEffects) {
const relicSetData = mapRelicSet?.[effect.key];
if (relicSetData) {
const setName = getLocaleName(locale, relicSetData.Name) || relicSetData.Name?.en || "Relic Set";
relicSetList.push(`• ${setName} (${effect.count}pc)`);
}
}
}
// Individual Relics (Slots 1-6)
const relicSlotNames = ["Head", "Hands", "Body", "Feet", "Planar Sphere", "Link Rope"];
const relicDetails: string[] = [];
if (relicStats && relicStats.length > 0) {
relicStats.forEach((r, idx) => {
if (!r) return;
const slotName = relicSlotNames[idx] || `Slot ${idx + 1}`;
const mainStatStr = r.mainAffix ? `${r.mainAffix.detail?.name || ""}: +${r.mainAffix.valueAffix}${r.mainAffix.detail?.unit || ""}` : "";
const subAffixesStr = r.subAffix?.filter(Boolean).map(s => `${s?.detail?.name || ""}+${s?.valueAffix}${s?.detail?.unit || ""}`).join(", ") || "";
relicDetails.push(` - ${slotName}: ${mainStatStr} | Subs: ${subAffixesStr}`);
});
}
const lines = [
`=== ${charName} (${eidolonRank} | ${charLevel}) ===`,
`Light Cone: ${lcText}`,
``,
`[Attributes]`,
...statsList,
``,
`[Relic Sets]`,
relicSetList.length > 0 ? relicSetList.join("\n") : "None",
];
if (relicDetails.length > 0) {
lines.push(``, `[Relic Details]`, ...relicDetails);
}
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!");
})
.catch(() => {
toast.error(locale === "vi" ? "Lỗi khi sao chép!" : "Failed to copy build info!");
});
}, [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!");
return;
}
if (isCopyingImage) return;
setIsCopyingImage(true);
try {
const { default: html2canvas } = await import("html2canvas-pro");
const canvas = await html2canvas(cardRef.current!, {
scale: 2,
backgroundColor: "#000000",
allowTaint: false,
useCORS: true,
logging: false,
width: 1600,
height: 650,
windowWidth: 1600,
windowHeight: 650,
onclone: (clonedDoc) => {
const clonedCard = clonedDoc.querySelector('[data-showcase-card="true"]') as HTMLElement | null;
if (clonedCard) {
clonedCard.style.transform = "none";
clonedCard.style.width = "1600px";
clonedCard.style.minHeight = "650px";
}
}
});
canvas.toBlob((blob) => {
if (!blob) {
toast.error("Failed to create image blob");
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!");
}).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!");
}).finally(() => {
setIsCopyingImage(false);
});
}, "image/png");
} catch {
toast.error("Error generating showcase image!");
setIsCopyingImage(false);
}
}, [avatarSelected, locale, isCopyingImage]);
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="bg-base-100 rounded-2xl p-4 sm:p-6 border border-base-300 shadow-sm space-y-6"> <div className="bg-base-100 rounded-2xl p-4 sm:p-6 border border-base-300 shadow-sm space-y-6">
@@ -523,19 +708,63 @@ export default function ShowCaseInfo() {
{transI18n("showcaseCard")} {transI18n("showcaseCard")}
</span> </span>
</h2> </h2>
<div className="flex items-center gap-2">
<button <button
className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs active-press" 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"
>
<Copy className="w-4 h-4" />
<span>{locale === "vi" ? "Sao chép Text" : "Copy Text"}</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"
>
{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>
</button>
<button
disabled={isSaving}
className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs active-press gap-1.5"
onClick={handleSaveImage} onClick={handleSaveImage}
> >
{transI18n("save")} {isSaving ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<Download className="w-4 h-4" />
)}
<span>{isSaving ? (locale === "vi" ? "Đang lưu..." : "Saving...") : transI18n("save")}</span>
</button> </button>
</div> </div>
</div>
<div className="overflow-auto rounded-2xl"> <div ref={wrapperRef} className="w-full flex justify-center items-start overflow-hidden">
<div
style={{
width: `${1600 * scale}px`,
height: `${650 * scale}px`,
position: "relative",
overflow: "hidden",
borderRadius: `${1.5 * scale}rem`,
flexShrink: 0,
}}
>
<div <div
ref={cardRef} ref={cardRef}
className="relative min-h-162.5 w-400 rounded-3xl transition-all duration-500 overflow-hidden text-white" data-showcase-card="true"
className="relative min-h-162.5 w-400 rounded-3xl transition-all duration-500 overflow-hidden text-white select-text"
style={{ style={{
width: "1600px",
minHeight: "650px",
transform: `scale(${scale})`,
transformOrigin: "top left",
backgroundColor: `${applyBrightness(avgColor, 0.3)}`, backgroundColor: `${applyBrightness(avgColor, 0.3)}`,
backdropFilter: "blur(50px)", backdropFilter: "blur(50px)",
WebkitBackdropFilter: "blur(50px)", WebkitBackdropFilter: "blur(50px)",
@@ -1026,10 +1255,10 @@ export default function ShowCaseInfo() {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }