Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at main 159 lines 5.2 kB view raw
1import React, { useState, useEffect, useRef } from "react"; 2import { Link } from "react-router-dom"; 3import Avatar from "../ui/Avatar"; 4import RichText from "./RichText"; 5import { getProfile } from "../../api/client"; 6import type { UserProfile } from "../../types"; 7import { Loader2 } from "lucide-react"; 8 9interface ProfileHoverCardProps { 10 did?: string; 11 handle?: string; 12 children: React.ReactNode; 13 className?: string; 14} 15 16export default function ProfileHoverCard({ 17 did, 18 handle, 19 children, 20 className, 21}: ProfileHoverCardProps) { 22 const [isOpen, setIsOpen] = useState(false); 23 const [profile, setProfile] = useState<UserProfile | null>(null); 24 const [loading, setLoading] = useState(false); 25 const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 26 const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); 27 const cardRef = useRef<HTMLDivElement>(null); 28 29 const handleMouseEnter = () => { 30 timeoutRef.current = setTimeout(async () => { 31 setIsOpen(true); 32 if (!profile && (did || handle)) { 33 setLoading(true); 34 try { 35 const identifier = did || handle || ""; 36 37 const [marginData, bskyData] = await Promise.all([ 38 getProfile(identifier).catch(() => null), 39 fetch( 40 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(identifier)}`, 41 ) 42 .then((res) => (res.ok ? res.json() : null)) 43 .catch(() => null), 44 ]); 45 46 const merged: UserProfile = { 47 did: marginData?.did || bskyData?.did || identifier, 48 handle: marginData?.handle || bskyData?.handle || "", 49 displayName: marginData?.displayName || bskyData?.displayName, 50 avatar: marginData?.avatar || bskyData?.avatar, 51 description: marginData?.description || bskyData?.description, 52 }; 53 54 setProfile(merged); 55 } catch (e) { 56 console.error("Failed to load profile", e); 57 } finally { 58 setLoading(false); 59 } 60 } 61 }, 400); 62 }; 63 64 const handleMouseLeave = () => { 65 if (timeoutRef.current) { 66 clearTimeout(timeoutRef.current); 67 timeoutRef.current = null; 68 } 69 closeTimeoutRef.current = setTimeout(() => { 70 setIsOpen(false); 71 }, 300); 72 }; 73 74 const handleCardMouseEnter = () => { 75 if (closeTimeoutRef.current) { 76 clearTimeout(closeTimeoutRef.current); 77 closeTimeoutRef.current = null; 78 } 79 }; 80 81 const handleCardMouseLeave = () => { 82 setIsOpen(false); 83 }; 84 85 useEffect(() => { 86 return () => { 87 if (timeoutRef.current) { 88 clearTimeout(timeoutRef.current); 89 } 90 if (closeTimeoutRef.current) { 91 clearTimeout(closeTimeoutRef.current); 92 } 93 }; 94 }, []); 95 96 return ( 97 <div 98 className={`relative inline-block ${className || ""}`} 99 onMouseEnter={handleMouseEnter} 100 onMouseLeave={handleMouseLeave} 101 ref={cardRef} 102 > 103 {children} 104 105 {isOpen && ( 106 <div 107 className="absolute z-50 left-0 top-full mt-2 w-72 bg-white dark:bg-surface-800 rounded-xl shadow-xl border border-surface-200 dark:border-surface-700 p-4 animate-in fade-in slide-in-from-top-1 duration-150" 108 onMouseEnter={handleCardMouseEnter} 109 onMouseLeave={handleCardMouseLeave} 110 > 111 {loading ? ( 112 <div className="flex items-center justify-center py-4"> 113 <Loader2 size={20} className="animate-spin text-primary-600" /> 114 </div> 115 ) : profile ? ( 116 <div className="space-y-3"> 117 <Link 118 to={`/profile/${profile.did}`} 119 className="flex items-start gap-3 group" 120 > 121 <Avatar 122 did={profile.did} 123 avatar={profile.avatar} 124 size="lg" 125 className="shrink-0" 126 /> 127 <div className="flex-1 min-w-0"> 128 <p className="font-semibold text-surface-900 dark:text-white truncate group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors"> 129 {profile.displayName || profile.handle} 130 </p> 131 <p className="text-sm text-surface-500 dark:text-surface-400 truncate"> 132 @{profile.handle} 133 </p> 134 </div> 135 </Link> 136 137 {profile.description && ( 138 <p className="text-sm text-surface-600 dark:text-surface-300 whitespace-pre-line line-clamp-3"> 139 <RichText text={profile.description} /> 140 </p> 141 )} 142 143 <Link 144 to={`/profile/${profile.did}`} 145 className="block w-full text-center py-2 bg-primary-600 hover:bg-primary-700 text-white text-sm font-medium rounded-lg transition-colors" 146 > 147 View Profile 148 </Link> 149 </div> 150 ) : ( 151 <p className="text-sm text-surface-500 text-center py-2"> 152 Profile not found 153 </p> 154 )} 155 </div> 156 )} 157 </div> 158 ); 159}