Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}