feat: implement custom theorycraft configuration and UI components for stage and lineup management
Gitea Auto Deploy / Deploy-Container (push) Successful in 39s
Gitea Auto Deploy / Deploy-Container (push) Successful in 39s
This commit is contained in:
+14
-11
@@ -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">
|
||||||
<ActionBar />
|
<div className="shrink-0">
|
||||||
<div className="flex-1 p-2 sm:p-3 sm:pt-1">
|
<ActionBar />
|
||||||
{children}
|
</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}
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
</ClientDataFetcher>
|
</ClientDataFetcher>
|
||||||
</ClientThemeWrapper>
|
</ClientThemeWrapper>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||||
|
|||||||
@@ -167,85 +167,83 @@ 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>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 items-center">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={uidInput}
|
value={uidInput}
|
||||||
onChange={(e) => setUidInput(e.target.value)}
|
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"
|
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"
|
placeholder="Enter UID"
|
||||||
/>
|
/>
|
||||||
<button onClick={handlerFetchData} className="btn btn-sm btn-success rounded-xl font-bold">
|
<button onClick={handlerFetchData} className="btn btn-sm btn-success rounded-xl font-bold">
|
||||||
{transI18n("getData")}
|
{transI18n("getData")}
|
||||||
|
</button>
|
||||||
|
{selectedCharacters.length > 0 && (
|
||||||
|
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
|
||||||
|
{transI18n("import")}
|
||||||
</button>
|
</button>
|
||||||
{selectedCharacters.length > 0 && (
|
|
||||||
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
|
|
||||||
{transI18n("import")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{Error && (
|
|
||||||
<div className="text-error text-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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{isLoading && (
|
{Error && (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="text-error text-sm font-semibold mt-2">😭 {Error}</div>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Character Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
{/* Selection Info */}
|
||||||
{validCharacters.map((character) => (
|
<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">
|
||||||
<CharacterInfoCard
|
<div className="flex items-center gap-2">
|
||||||
key={character.avatarId}
|
<span className="text-xs sm:text-sm font-bold text-base-content">
|
||||||
character={toCharacterInfoCard(character)}
|
{transI18n("selectedCharacters")}:
|
||||||
selectedCharacters={selectedCharacters}
|
</span>
|
||||||
onCharacterToggle={handleCharacterToggle}
|
<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>
|
||||||
</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>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,74 +189,72 @@ 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>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
<div className="flex flex-wrap gap-4 items-center">
|
||||||
<fieldset className="fieldset">
|
<fieldset className="fieldset">
|
||||||
<legend className="fieldset-legend text-xs font-semibold">{transI18n("pickAFile")}</legend>
|
<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} />
|
<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>
|
<label className="label text-[11px] text-base-content/60">{transI18n("onlySupportFreeSRJsonFile")}</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{selectedCharacters.length > 0 && (
|
{selectedCharacters.length > 0 && (
|
||||||
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
|
<button onClick={handleImport} className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs">
|
||||||
{transI18n("import")}
|
{transI18n("import")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{Error && (
|
|
||||||
<div className="text-error text-sm font-semibold mt-2">😭 {Error}</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>
|
</div>
|
||||||
{isLoading && (
|
{Error && (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="text-error text-sm font-semibold mt-2">😭 {Error}</div>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{/* Character Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
{/* Selection Info */}
|
||||||
{validFreeSRData && Object.values(validFreeSRData.avatars).filter(it => mapAvatar?.[it.avatar_id]).map((character) => {
|
<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">
|
||||||
return (
|
<div className="flex items-center gap-2">
|
||||||
<CharacterInfoCard
|
<span className="text-xs sm:text-sm font-bold text-base-content">
|
||||||
key={character.avatar_id}
|
{transI18n("selectedCharacters")}:
|
||||||
character={toCharacterInfoCard(validFreeSRData, character)}
|
</span>
|
||||||
selectedCharacters={selectedCharacters}
|
<span className="badge badge-primary font-mono font-bold text-xs">
|
||||||
onCharacterToggle={handleCharacterToggle}
|
{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>
|
||||||
</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>
|
||||||
|
</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 (
|
||||||
|
<CharacterInfoCard
|
||||||
|
key={character.avatar_id}
|
||||||
|
character={toCharacterInfoCard(validFreeSRData, character)}
|
||||||
|
selectedCharacters={selectedCharacters}
|
||||||
|
onCharacterToggle={handleCharacterToggle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
scale: 2,
|
const canvas = await html2canvas(cardRef.current!, {
|
||||||
backgroundColor: "#000000",
|
scale: 2,
|
||||||
allowTaint: false,
|
backgroundColor: "#000000",
|
||||||
useCORS: true
|
allowTaint: false,
|
||||||
})
|
useCORS: true,
|
||||||
)
|
logging: false,
|
||||||
.then((canvas: HTMLCanvasElement) => {
|
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 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.error("Error generating showcase card!");
|
toast.success(locale === "vi" ? "Đã lưu ảnh thành công!" : "Image saved successfully!");
|
||||||
});
|
}, "image/png");
|
||||||
}, [avatarSelected, locale, transI18n]);
|
} catch {
|
||||||
|
toast.error("Error generating showcase card!");
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}, [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,27 +708,71 @@ export default function ShowCaseInfo() {
|
|||||||
{transI18n("showcaseCard")}
|
{transI18n("showcaseCard")}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
className="btn btn-sm btn-primary rounded-xl font-bold shadow-xs active-press"
|
<button
|
||||||
onClick={handleSaveImage}
|
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}
|
||||||
{transI18n("save")}
|
title="Copy build summary as text"
|
||||||
</button>
|
>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto rounded-2xl">
|
<div ref={wrapperRef} className="w-full flex justify-center items-start overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
|
||||||
className="relative min-h-162.5 w-400 rounded-3xl transition-all duration-500 overflow-hidden text-white"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `${applyBrightness(avgColor, 0.3)}`,
|
width: `${1600 * scale}px`,
|
||||||
backdropFilter: "blur(50px)",
|
height: `${650 * scale}px`,
|
||||||
WebkitBackdropFilter: "blur(50px)",
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
borderRadius: `${1.5 * scale}rem`,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute bottom-2 left-4 z-10">
|
<div
|
||||||
<span className="shadow-black [text-shadow:1px_1px_2px_var(--tw-shadow-color)]"></span>
|
ref={cardRef}
|
||||||
</div>
|
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={{
|
||||||
|
width: "1600px",
|
||||||
|
minHeight: "650px",
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
transformOrigin: "top left",
|
||||||
|
backgroundColor: `${applyBrightness(avgColor, 0.3)}`,
|
||||||
|
backdropFilter: "blur(50px)",
|
||||||
|
WebkitBackdropFilter: "blur(50px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute bottom-2 left-4 z-10">
|
||||||
|
<span className="shadow-black [text-shadow:1px_1px_2px_var(--tw-shadow-color)]"></span>
|
||||||
|
</div>
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<div
|
<div
|
||||||
className="relative min-h-162.5 w-[24%]"
|
className="relative min-h-162.5 w-[24%]"
|
||||||
@@ -1026,10 +1255,10 @@ export default function ShowCaseInfo() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user