(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at frontend-rewrite 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}