refactor: enhance showcase card functionality with image copy and build text features; improve layout responsiveness
Gitea Auto Deploy / Deploy-Container (push) Successful in 39s

This commit is contained in:
2026-08-27 19:00:26 +07:00
parent c28c0fb0f9
commit 630002ac13
2 changed files with 276 additions and 44 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>
+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>
); );
} }