feat: add profile card
This commit is contained in:
parent
a38936fb9a
commit
18ee834b1a
4 changed files with 902 additions and 20 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Github, Linkedin, Mail, MapPin, Phone } from "lucide-react";
|
||||
import ProfileCard from "@/components/profile-card";
|
||||
import avatar from "@/assets/avatar.png";
|
||||
|
||||
interface HeroSectionProps {
|
||||
|
|
@ -35,24 +35,15 @@ export function HeroSection({ profile }: HeroSectionProps) {
|
|||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<Avatar className="w-32 h-32 mx-auto mb-6">
|
||||
<AvatarImage
|
||||
src={avatar}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
<div className="flex justify-center">
|
||||
<ProfileCard
|
||||
name="Afiq Zudin Hadi"
|
||||
title={profile.title}
|
||||
avatarUrl={avatar}
|
||||
showUserInfo={false}
|
||||
enableTilt={true}
|
||||
/>
|
||||
<AvatarFallback className="text-4xl font-bold bg-gradient-to-br from-primary to-primary/80 text-primary-foreground">
|
||||
{profile.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<h1 className="text-5xl md:text-6xl font-bold mb-4">
|
||||
{profile.name}
|
||||
</h1>
|
||||
<h2 className="text-2xl md:text-3xl text-primary font-medium mb-6">
|
||||
{profile.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xl text-muted-foreground max-w-3xl mx-auto leading-relaxed">
|
||||
{profile.about?.description?.map((desc, idx) => (
|
||||
<span key={idx} className={idx > 0 ? "block mt-2" : undefined}>
|
||||
|
|
|
|||
332
app/components/profile-card.tsx
Normal file
332
app/components/profile-card.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue