"use client"; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react"; import { cn } from "@/lib/utils"; export interface TwitterCardProps { username: string; name?: string; avatarUrl?: string; bannerUrl?: string; staticCard?: boolean; joinedDate?: string; year?: number | string; text?: string; linkText?: string; href?: string; enableLinkTilt?: boolean; linkTiltMaxRotate?: number; enableCardTilt?: boolean; cardTiltMaxRotate?: number; className?: string; popoverClassName?: string; linkClassName?: string; labelClassName?: string; } const VIEWPORT_MARGIN = 24; const XIcon = ({ className }: { className?: string }) => ( ); const VerifiedBadge = ({ className }: { className?: string }) => ( ); const LocationIcon = ({ className }: { className?: string }) => ( ); const LinkIcon = ({ className }: { className?: string }) => ( ); const CalendarIcon = ({ className }: { className?: string }) => ( ); const DefaultAvatar = ({ className }: { className?: string }) => (
); export const TwitterCard = ({ username, name = "Twitter User", avatarUrl, bannerUrl, staticCard = false, joinedDate, year = 2026, text = "Follow me on", linkText = "X", href, enableLinkTilt = true, linkTiltMaxRotate = 5, enableCardTilt = true, cardTiltMaxRotate = 5, className, popoverClassName, linkClassName, labelClassName, }: TwitterCardProps) => { const prefersReducedMotion = useReducedMotion(); const [isHovered, setIsHovered] = useState(false); const [failedAvatar, setFailedAvatar] = useState(null); const [failedBanner, setFailedBanner] = useState(null); const profileUrl = href || `https://x.com/${username}`; const linkRef = useRef(null); const cardRef = useRef(null); const containerRef = useRef(null); const tapRevealRef = useRef(false); const [cardShift, setCardShift] = useState(0); const [profile, setProfile] = useState({ name: name || "Twitter User", avatarUrl: avatarUrl || "", bannerUrl: bannerUrl || "", bio: "This user hasn't added a bio yet.", following: 0, followers: 0, joinedDate: joinedDate || `Joined ${year}`, location: "", website: null as { url: string; display_url: string } | null, verified: false, }); useEffect(() => { if (!username) return; let isMounted = true; const controller = new AbortController(); fetch(`https://api.fxtwitter.com/${username}`, { signal: controller.signal, }) .then((res) => { if (!res.ok) throw new Error("API request failed"); return res.json(); }) .then((data) => { if (!isMounted) return; if (data.code === 200 && data.user) { const user = data.user; const formattedJoined = user.joined ? `Joined ${new Date(user.joined).toLocaleDateString("en-US", { month: "long", year: "numeric", })}` : joinedDate || `Joined ${year}`; setProfile({ name: user.name || name || "Twitter User", avatarUrl: user.avatar_url?.replace("_normal", "_400x400") || avatarUrl || "", bannerUrl: user.banner_url || bannerUrl || "", bio: user.description || "This user hasn't added a bio yet.", following: user.following ?? 0, followers: user.followers ?? 0, joinedDate: formattedJoined, location: user.location || "", website: user.website || null, verified: Boolean(user.verification?.verified), }); } }) .catch(() => {}); return () => { isMounted = false; controller.abort(); }; }, [username, name, avatarUrl, bannerUrl, joinedDate, year]); const linkX = useMotionValue(0); const linkY = useMotionValue(0); const linkSpringX = useSpring(linkX, { stiffness: 320, damping: 24 }); const linkSpringY = useSpring(linkY, { stiffness: 320, damping: 24 }); const linkRotateX = useTransform( linkSpringY, [-1, 1], [linkTiltMaxRotate, -linkTiltMaxRotate], ); const linkRotateY = useTransform( linkSpringX, [-1, 1], [-linkTiltMaxRotate, linkTiltMaxRotate], ); const cardX = useMotionValue(0); const cardY = useMotionValue(0); const cardSpringX = useSpring(cardX, { stiffness: 300, damping: 24, mass: 0.6, }); const cardSpringY = useSpring(cardY, { stiffness: 300, damping: 24, mass: 0.6, }); const cardRotateX = useTransform( cardSpringY, [-1, 1], [cardTiltMaxRotate, -cardTiltMaxRotate], ); const cardRotateY = useTransform( cardSpringX, [-1, 1], [-cardTiltMaxRotate, cardTiltMaxRotate], ); const handleLinkMouseMove = useCallback( (e: React.MouseEvent) => { if (!enableLinkTilt || !linkRef.current || prefersReducedMotion) return; const rect = linkRef.current.getBoundingClientRect(); const nx = (e.clientX - rect.left - rect.width / 2) / (rect.width / 2); const ny = (e.clientY - rect.top - rect.height / 2) / (rect.height / 2); linkX.set(Math.max(-1, Math.min(1, nx))); linkY.set(Math.max(-1, Math.min(1, ny))); }, [enableLinkTilt, linkX, linkY, prefersReducedMotion], ); const handleCardMouseMove = useCallback( (e: React.MouseEvent) => { if (!enableCardTilt || !cardRef.current || prefersReducedMotion) return; const rect = cardRef.current.getBoundingClientRect(); const nx = (e.clientX - rect.left - rect.width / 2) / (rect.width / 2); const ny = (e.clientY - rect.top - rect.height / 2) / (rect.height / 2); cardX.set(Math.max(-1, Math.min(1, nx))); cardY.set(Math.max(-1, Math.min(1, ny))); }, [enableCardTilt, cardX, cardY, prefersReducedMotion], ); const handleContainerMouseLeave = useCallback(() => { setIsHovered(false); linkX.set(0); linkY.set(0); cardX.set(0); cardY.set(0); }, [linkX, linkY, cardX, cardY]); useLayoutEffect(() => { if (!isHovered) return; const updateShift = () => { const container = containerRef.current; const card = cardRef.current; if (!container || !card) return; const box = container.getBoundingClientRect(); const width = card.offsetWidth; const naturalLeft = box.left + box.width / 2 - width / 2; const maxLeft = window.innerWidth - VIEWPORT_MARGIN - width; const left = Math.max(VIEWPORT_MARGIN, Math.min(maxLeft, naturalLeft)); setCardShift(left - naturalLeft); }; updateShift(); window.addEventListener("resize", updateShift); return () => window.removeEventListener("resize", updateShift); }, [isHovered]); useEffect(() => { if (!isHovered) return; const closeOnOutsidePointer = (event: PointerEvent) => { if (!containerRef.current?.contains(event.target as Node)) { handleContainerMouseLeave(); } }; document.addEventListener("pointerdown", closeOnOutsidePointer); return () => document.removeEventListener("pointerdown", closeOnOutsidePointer); }, [isHovered, handleContainerMouseLeave]); const handleTriggerPointerDown = (e: React.PointerEvent) => { tapRevealRef.current = e.pointerType !== "mouse" && !isHovered; }; const handleTriggerClick = (e: React.MouseEvent) => { if (!tapRevealRef.current) return; tapRevealRef.current = false; e.preventDefault(); setIsHovered(true); }; const handleContainerBlur = (e: React.FocusEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { handleContainerMouseLeave(); } }; const handleContainerKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape" && isHovered) handleContainerMouseLeave(); }; const handleStaticMouseLeave = useCallback(() => { cardX.set(0); cardY.set(0); }, [cardX, cardY]); const formatCount = (count: number | string | undefined): string => { if (count === undefined || count === null) return "0"; if (typeof count === "string") return count; if (count >= 1000000) { return `${(count / 1000000).toFixed(1).replace(/\.0$/, "")}M`; } if (count >= 1000) { return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}K`; } return count.toLocaleString("en-US"); }; const ProfileLink = staticCard ? "span" : "a"; const cardContent = (
{profile.bannerUrl && failedBanner !== profile.bannerUrl ? ( Profile Banner setFailedBanner(profile.bannerUrl)} className="h-full w-full object-cover" /> ) : (
)}
{profile.avatarUrl && failedAvatar !== profile.avatarUrl ? ( {`${profile.name}'s setFailedAvatar(profile.avatarUrl)} className="h-full w-full object-cover" /> ) : ( )}
{profile.name} {profile.verified && ( )}
@{username}

{profile.bio}

{profile.location && (
{profile.location}
)} {profile.website && (
{profile.website.display_url}
)} {profile.joinedDate && (
{profile.joinedDate}
)}
{formatCount(profile.following)} Following
{formatCount(profile.followers)} Followers
); if (staticCard) { return (
{cardContent}
); } return (
{text}
setIsHovered(true)} onMouseLeave={handleContainerMouseLeave} onFocus={() => setIsHovered(true)} onBlur={handleContainerBlur} onKeyDown={handleContainerKeyDown} > {linkText}
{cardContent}
); }; export default TwitterCard;