diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 14306fb..4444733 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -64,9 +64,9 @@ export default async function RootLayout({ const locale = await getLocale() return ( - + @@ -74,21 +74,24 @@ export default async function RootLayout({ -
+
-
-
diff --git a/src/components/showcaseCard/index.tsx b/src/components/showcaseCard/index.tsx index 4e1978e..d1acaaf 100644 --- a/src/components/showcaseCard/index.tsx +++ b/src/components/showcaseCard/index.tsx @@ -15,6 +15,7 @@ import useDetailDataStore from '@/stores/detailDataStore'; import useCurrentDataStore from '@/stores/currentDataStore'; import { getLocaleName } from '@/helper'; import { useShallow } from 'zustand/react/shallow'; +import { Copy, Download, Image as ImageIcon } from 'lucide-react'; export default function ShowCaseInfo() { @@ -36,6 +37,8 @@ export default function ShowCaseInfo() { useShallow((state) => ({ avatars: state.avatars })) ) const [avgColor, setAvgColor] = useState('#222'); + const [isSaving, setIsSaving] = useState(false); + const [isCopyingImage, setIsCopyingImage] = useState(false); const imgRef = useRef(null); const cardRef = useRef(null) const { locale } = useLocaleStore( @@ -43,31 +46,56 @@ export default function ShowCaseInfo() { ) const transI18n = useTranslations("DataPage") - const handleSaveImage = useCallback(() => { + const handleSaveImage = useCallback(async () => { if (cardRef.current === null || !avatarSelected) { toast.error("Avatar showcase not found!"); return; } + if (isSaving) return; - import("html2canvas-pro") - .then(({ default: html2canvas }) => - html2canvas(cardRef.current!, { - scale: 2, - backgroundColor: "#000000", - allowTaint: false, - useCORS: true - }) - ) - .then((canvas: HTMLCanvasElement) => { + setIsSaving(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 generate image!"); + setIsSaving(false); + return; + } + const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.download = `${getNameChar(locale, transI18n, avatarSelected)}_showcase.png`; - link.href = canvas.toDataURL("image/png"); + link.href = url; link.click(); - }) - .catch(() => { - toast.error("Error generating showcase card!"); - }); - }, [avatarSelected, locale, transI18n]); + URL.revokeObjectURL(url); + 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!"); + setIsSaving(false); + } + }, [avatarSelected, locale, transI18n, isSaving]); useEffect(() => { 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")}`; }, []) + const wrapperRef = useRef(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 (
@@ -523,27 +708,71 @@ export default function ShowCaseInfo() { {transI18n("showcaseCard")} - +
+ + + +
-
+
-
- -
+
+
+ +
-
+
); }