332 lines
8.9 KiB
TypeScript
332 lines
8.9 KiB
TypeScript
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
|
import "@/styles/profile-card.css";
|
|
|
|
interface ProfileCardProps {
|
|
avatarUrl: string;
|
|
iconUrl?: string;
|
|
grainUrl?: string;
|
|
behindGradient?: string;
|
|
innerGradient?: string;
|
|
showBehindGradient?: boolean;
|
|
className?: string;
|
|
enableTilt?: boolean;
|
|
miniAvatarUrl?: string;
|
|
name?: string;
|
|
title?: string;
|
|
handle?: string;
|
|
status?: string;
|
|
contactText?: string;
|
|
showUserInfo?: boolean;
|
|
onContactClick?: () => void;
|
|
}
|
|
|
|
const DEFAULT_BEHIND_GRADIENT =
|
|
"radial-gradient(farthest-side circle at var(--pointer-x) var(--pointer-y),hsla(266,100%,90%,var(--card-opacity)) 4%,hsla(266,50%,80%,calc(var(--card-opacity)*0.75)) 10%,hsla(266,25%,70%,calc(var(--card-opacity)*0.5)) 50%,hsla(266,0%,60%,0) 100%),radial-gradient(35% 52% at 55% 20%,#00ffaac4 0%,#073aff00 100%),radial-gradient(100% 100% at 50% 50%,#00c1ffff 1%,#073aff00 76%),conic-gradient(from 124deg at 50% 50%,#c137ffff 0%,#07c6ffff 40%,#07c6ffff 60%,#c137ffff 100%)";
|
|
|
|
const DEFAULT_INNER_GRADIENT =
|
|
"linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)";
|
|
|
|
const ANIMATION_CONFIG = {
|
|
SMOOTH_DURATION: 600,
|
|
INITIAL_DURATION: 1500,
|
|
INITIAL_X_OFFSET: 70,
|
|
INITIAL_Y_OFFSET: 60,
|
|
} as const;
|
|
|
|
const clamp = (value: number, min = 0, max = 100): number =>
|
|
Math.min(Math.max(value, min), max);
|
|
|
|
const round = (value: number, precision = 3): number =>
|
|
parseFloat(value.toFixed(precision));
|
|
|
|
const adjust = (
|
|
value: number,
|
|
fromMin: number,
|
|
fromMax: number,
|
|
toMin: number,
|
|
toMax: number
|
|
): number =>
|
|
round(toMin + ((toMax - toMin) * (value - fromMin)) / (fromMax - fromMin));
|
|
|
|
const easeInOutCubic = (x: number): number =>
|
|
x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
|
|
|
|
const ProfileCardComponent: React.FC<ProfileCardProps> = ({
|
|
avatarUrl = "<Placeholder for avatar URL>",
|
|
iconUrl = "<Placeholder for icon URL>",
|
|
grainUrl = "<Placeholder for grain URL>",
|
|
behindGradient,
|
|
innerGradient,
|
|
showBehindGradient = true,
|
|
className = "",
|
|
enableTilt = true,
|
|
miniAvatarUrl,
|
|
name = "Javi A. Torres",
|
|
title = "Software Engineer",
|
|
handle = "javicodes",
|
|
status = "Online",
|
|
contactText = "Contact",
|
|
showUserInfo = true,
|
|
onContactClick,
|
|
}) => {
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
|
|
const animationHandlers = useMemo(() => {
|
|
if (!enableTilt) return null;
|
|
|
|
let rafId: number | null = null;
|
|
|
|
const updateCardTransform = (
|
|
offsetX: number,
|
|
offsetY: number,
|
|
card: HTMLElement,
|
|
wrap: HTMLElement
|
|
) => {
|
|
const width = card.clientWidth;
|
|
const height = card.clientHeight;
|
|
|
|
const percentX = clamp((100 / width) * offsetX);
|
|
const percentY = clamp((100 / height) * offsetY);
|
|
|
|
const centerX = percentX - 50;
|
|
const centerY = percentY - 50;
|
|
|
|
const properties = {
|
|
"--pointer-x": `${percentX}%`,
|
|
"--pointer-y": `${percentY}%`,
|
|
"--background-x": `${adjust(percentX, 0, 100, 35, 65)}%`,
|
|
"--background-y": `${adjust(percentY, 0, 100, 35, 65)}%`,
|
|
"--pointer-from-center": `${clamp(
|
|
Math.hypot(percentY - 50, percentX - 50) / 50,
|
|
0,
|
|
1
|
|
)}`,
|
|
"--pointer-from-top": `${percentY / 100}`,
|
|
"--pointer-from-left": `${percentX / 100}`,
|
|
"--rotate-x": `${round(-(centerX / 5))}deg`,
|
|
"--rotate-y": `${round(centerY / 4)}deg`,
|
|
};
|
|
|
|
Object.entries(properties).forEach(([property, value]) => {
|
|
wrap.style.setProperty(property, value);
|
|
});
|
|
};
|
|
|
|
const createSmoothAnimation = (
|
|
duration: number,
|
|
startX: number,
|
|
startY: number,
|
|
card: HTMLElement,
|
|
wrap: HTMLElement
|
|
) => {
|
|
const startTime = performance.now();
|
|
const targetX = wrap.clientWidth / 2;
|
|
const targetY = wrap.clientHeight / 2;
|
|
|
|
const animationLoop = (currentTime: number) => {
|
|
const elapsed = currentTime - startTime;
|
|
const progress = clamp(elapsed / duration);
|
|
const easedProgress = easeInOutCubic(progress);
|
|
|
|
const currentX = adjust(easedProgress, 0, 1, startX, targetX);
|
|
const currentY = adjust(easedProgress, 0, 1, startY, targetY);
|
|
|
|
updateCardTransform(currentX, currentY, card, wrap);
|
|
|
|
if (progress < 1) {
|
|
rafId = requestAnimationFrame(animationLoop);
|
|
}
|
|
};
|
|
|
|
rafId = requestAnimationFrame(animationLoop);
|
|
};
|
|
|
|
return {
|
|
updateCardTransform,
|
|
createSmoothAnimation,
|
|
cancelAnimation: () => {
|
|
if (rafId) {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = null;
|
|
}
|
|
},
|
|
};
|
|
}, [enableTilt]);
|
|
|
|
const handlePointerMove = useCallback(
|
|
(event: PointerEvent) => {
|
|
const card = cardRef.current;
|
|
const wrap = wrapRef.current;
|
|
|
|
if (!card || !wrap || !animationHandlers) return;
|
|
|
|
const rect = card.getBoundingClientRect();
|
|
animationHandlers.updateCardTransform(
|
|
event.clientX - rect.left,
|
|
event.clientY - rect.top,
|
|
card,
|
|
wrap
|
|
);
|
|
},
|
|
[animationHandlers]
|
|
);
|
|
|
|
const handlePointerEnter = useCallback(() => {
|
|
const card = cardRef.current;
|
|
const wrap = wrapRef.current;
|
|
|
|
if (!card || !wrap || !animationHandlers) return;
|
|
|
|
animationHandlers.cancelAnimation();
|
|
wrap.classList.add("active");
|
|
card.classList.add("active");
|
|
}, [animationHandlers]);
|
|
|
|
const handlePointerLeave = useCallback(
|
|
(event: PointerEvent) => {
|
|
const card = cardRef.current;
|
|
const wrap = wrapRef.current;
|
|
|
|
if (!card || !wrap || !animationHandlers) return;
|
|
|
|
animationHandlers.createSmoothAnimation(
|
|
ANIMATION_CONFIG.SMOOTH_DURATION,
|
|
event.offsetX,
|
|
event.offsetY,
|
|
card,
|
|
wrap
|
|
);
|
|
wrap.classList.remove("active");
|
|
card.classList.remove("active");
|
|
},
|
|
[animationHandlers]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!enableTilt || !animationHandlers) return;
|
|
|
|
const card = cardRef.current;
|
|
const wrap = wrapRef.current;
|
|
|
|
if (!card || !wrap) return;
|
|
|
|
const pointerMoveHandler = handlePointerMove as EventListener;
|
|
const pointerEnterHandler = handlePointerEnter as EventListener;
|
|
const pointerLeaveHandler = handlePointerLeave as EventListener;
|
|
|
|
card.addEventListener("pointerenter", pointerEnterHandler);
|
|
card.addEventListener("pointermove", pointerMoveHandler);
|
|
card.addEventListener("pointerleave", pointerLeaveHandler);
|
|
|
|
const initialX = wrap.clientWidth - ANIMATION_CONFIG.INITIAL_X_OFFSET;
|
|
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET;
|
|
|
|
animationHandlers.updateCardTransform(initialX, initialY, card, wrap);
|
|
animationHandlers.createSmoothAnimation(
|
|
ANIMATION_CONFIG.INITIAL_DURATION,
|
|
initialX,
|
|
initialY,
|
|
card,
|
|
wrap
|
|
);
|
|
|
|
return () => {
|
|
card.removeEventListener("pointerenter", pointerEnterHandler);
|
|
card.removeEventListener("pointermove", pointerMoveHandler);
|
|
card.removeEventListener("pointerleave", pointerLeaveHandler);
|
|
animationHandlers.cancelAnimation();
|
|
};
|
|
}, [
|
|
enableTilt,
|
|
animationHandlers,
|
|
handlePointerMove,
|
|
handlePointerEnter,
|
|
handlePointerLeave,
|
|
]);
|
|
|
|
const cardStyle = useMemo(
|
|
() =>
|
|
({
|
|
"--icon": iconUrl ? `url(${iconUrl})` : "none",
|
|
"--grain": grainUrl ? `url(${grainUrl})` : "none",
|
|
"--behind-gradient": showBehindGradient
|
|
? behindGradient ?? DEFAULT_BEHIND_GRADIENT
|
|
: "none",
|
|
"--inner-gradient": innerGradient ?? DEFAULT_INNER_GRADIENT,
|
|
} as React.CSSProperties),
|
|
[iconUrl, grainUrl, showBehindGradient, behindGradient, innerGradient]
|
|
);
|
|
|
|
const handleContactClick = useCallback(() => {
|
|
onContactClick?.();
|
|
}, [onContactClick]);
|
|
|
|
return (
|
|
<div
|
|
ref={wrapRef}
|
|
className={`pc-card-wrapper ${className}`.trim()}
|
|
style={cardStyle}
|
|
>
|
|
<section ref={cardRef} className="pc-card">
|
|
<div className="pc-inside">
|
|
<div className="pc-shine" />
|
|
<div className="pc-glare" />
|
|
<div className="pc-content pc-avatar-content">
|
|
<img
|
|
className="avatar"
|
|
src={avatarUrl}
|
|
alt={`${name || "User"} avatar`}
|
|
loading="lazy"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.display = "none";
|
|
}}
|
|
/>
|
|
{showUserInfo && (
|
|
<div className="pc-user-info">
|
|
<div className="pc-user-details">
|
|
<div className="pc-mini-avatar">
|
|
<img
|
|
src={miniAvatarUrl || avatarUrl}
|
|
alt={`${name || "User"} mini avatar`}
|
|
loading="lazy"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.opacity = "0.5";
|
|
target.src = avatarUrl;
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="pc-user-text">
|
|
<div className="pc-handle">@{handle}</div>
|
|
<div className="pc-status">{status}</div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
className="pc-contact-btn"
|
|
onClick={handleContactClick}
|
|
style={{ pointerEvents: "auto" }}
|
|
type="button"
|
|
aria-label={`Contact ${name || "user"}`}
|
|
>
|
|
{contactText}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="pc-content">
|
|
<div className="pc-details">
|
|
<h3>{name}</h3>
|
|
<p>{title}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ProfileCard = React.memo(ProfileCardComponent);
|
|
|
|
export default ProfileCard;
|