Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 390 lines 13 kB view raw
1import { useState, useEffect, useRef } from "react"; 2import { Link, useNavigate } from "react-router-dom"; 3import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4import { getByTarget, searchActors } from "../api/client"; 5import { useAuth } from "../context/AuthContext"; 6import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; 7import { Copy, Check, ExternalLink } from "lucide-react"; 8 9export default function Url() { 10 const { user } = useAuth(); 11 const navigate = useNavigate(); 12 const [url, setUrl] = useState(""); 13 const [annotations, setAnnotations] = useState([]); 14 const [highlights, setHighlights] = useState([]); 15 const [loading, setLoading] = useState(false); 16 const [searched, setSearched] = useState(false); 17 const [error, setError] = useState(null); 18 const [activeTab, setActiveTab] = useState("all"); 19 const [copied, setCopied] = useState(false); 20 21 const [suggestions, setSuggestions] = useState([]); 22 const [showSuggestions, setShowSuggestions] = useState(false); 23 const [selectedIndex, setSelectedIndex] = useState(-1); 24 const inputRef = useRef(null); 25 const suggestionsRef = useRef(null); 26 27 useEffect(() => { 28 const timer = setTimeout(async () => { 29 const isUrl = url.includes("http") || url.includes("://"); 30 if (url.length >= 2 && !isUrl) { 31 try { 32 const data = await searchActors(url); 33 setSuggestions(data.actors || []); 34 setShowSuggestions(true); 35 } catch { 36 // ignore 37 } 38 } else { 39 setSuggestions([]); 40 setShowSuggestions(false); 41 } 42 }, 300); 43 return () => clearTimeout(timer); 44 }, [url]); 45 46 useEffect(() => { 47 const handleClickOutside = (e) => { 48 if ( 49 suggestionsRef.current && 50 !suggestionsRef.current.contains(e.target) && 51 inputRef.current && 52 !inputRef.current.contains(e.target) 53 ) { 54 setShowSuggestions(false); 55 } 56 }; 57 document.addEventListener("mousedown", handleClickOutside); 58 return () => document.removeEventListener("mousedown", handleClickOutside); 59 }, []); 60 61 const handleKeyDown = (e) => { 62 if (!showSuggestions || suggestions.length === 0) return; 63 64 if (e.key === "ArrowDown") { 65 e.preventDefault(); 66 setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); 67 } else if (e.key === "ArrowUp") { 68 e.preventDefault(); 69 setSelectedIndex((prev) => Math.max(prev - 1, -1)); 70 } else if (e.key === "Enter" && selectedIndex >= 0) { 71 e.preventDefault(); 72 selectSuggestion(suggestions[selectedIndex]); 73 } else if (e.key === "Escape") { 74 setShowSuggestions(false); 75 } 76 }; 77 78 const selectSuggestion = (actor) => { 79 navigate(`/profile/${encodeURIComponent(actor.handle)}`); 80 }; 81 82 const handleSearch = async (e) => { 83 e.preventDefault(); 84 if (!url.trim()) return; 85 86 setLoading(true); 87 setError(null); 88 setSearched(true); 89 90 const isProtocol = url.startsWith("http://") || url.startsWith("https://"); 91 if (!isProtocol) { 92 try { 93 const actorRes = await searchActors(url); 94 if (actorRes?.actors?.length > 0) { 95 const match = actorRes.actors[0]; 96 navigate(`/profile/${encodeURIComponent(match.handle)}`); 97 return; 98 } 99 } catch { 100 // ignore 101 } 102 } 103 104 try { 105 const data = await getByTarget(url); 106 setAnnotations(data.annotations || []); 107 setHighlights(data.highlights || []); 108 } catch (err) { 109 setError(err.message); 110 } finally { 111 setLoading(false); 112 } 113 }; 114 115 const myAnnotations = user 116 ? annotations.filter((a) => (a.creator?.did || a.author?.did) === user.did) 117 : []; 118 const myHighlights = user 119 ? highlights.filter((h) => (h.creator?.did || h.author?.did) === user.did) 120 : []; 121 const myItemsCount = myAnnotations.length + myHighlights.length; 122 123 const getShareUrl = () => { 124 if (!user?.handle || !url) return null; 125 return `${window.location.origin}/${user.handle}/url/${url}`; 126 }; 127 128 const handleCopyShareLink = async () => { 129 const shareUrl = getShareUrl(); 130 if (!shareUrl) return; 131 try { 132 await navigator.clipboard.writeText(shareUrl); 133 setCopied(true); 134 setTimeout(() => setCopied(false), 2000); 135 } catch { 136 prompt("Copy this link:", shareUrl); 137 } 138 }; 139 140 const totalItems = annotations.length + highlights.length; 141 142 const renderResults = () => { 143 if (activeTab === "annotations" && annotations.length === 0) { 144 return ( 145 <div className="empty-state"> 146 <div className="empty-state-icon"> 147 <PenIcon size={32} /> 148 </div> 149 <h3 className="empty-state-title">No annotations</h3> 150 </div> 151 ); 152 } 153 154 return ( 155 <> 156 {(activeTab === "all" || activeTab === "annotations") && 157 annotations.map((a) => <AnnotationCard key={a.id} annotation={a} />)} 158 {(activeTab === "all" || activeTab === "highlights") && 159 highlights.map((h) => <HighlightCard key={h.id} highlight={h} />)} 160 </> 161 ); 162 }; 163 164 return ( 165 <div className="url-page"> 166 <div className="page-header"> 167 <h1 className="page-title">Explore</h1> 168 <p className="page-description"> 169 Search for a URL to view its context layer, or find a user by their 170 handle 171 </p> 172 </div> 173 174 <form 175 onSubmit={handleSearch} 176 className="url-input-wrapper" 177 style={{ position: "relative" }} 178 > 179 <div className="url-input-container"> 180 <input 181 ref={inputRef} 182 type="text" 183 value={url} 184 onChange={(e) => setUrl(e.target.value)} 185 onKeyDown={handleKeyDown} 186 placeholder="https://... or handle" 187 className="url-input" 188 autoComplete="off" 189 required 190 /> 191 <button type="submit" className="btn btn-primary" disabled={loading}> 192 {loading ? "Searching..." : "Search"} 193 </button> 194 </div> 195 196 {showSuggestions && suggestions.length > 0 && ( 197 <div 198 className="login-suggestions" 199 ref={suggestionsRef} 200 style={{ 201 position: "absolute", 202 top: "100%", 203 left: 0, 204 right: 0, 205 marginTop: "8px", 206 width: "100%", 207 zIndex: 50, 208 background: "var(--bg-primary)", 209 borderRadius: "12px", 210 boxShadow: "var(--shadow-lg)", 211 border: "1px solid var(--border)", 212 maxHeight: "300px", 213 overflowY: "auto", 214 }} 215 > 216 {suggestions.map((actor, index) => ( 217 <button 218 key={actor.did} 219 type="button" 220 className={`login-suggestion ${index === selectedIndex ? "selected" : ""}`} 221 onClick={() => selectSuggestion(actor)} 222 style={{ 223 width: "100%", 224 textAlign: "left", 225 padding: "12px", 226 display: "flex", 227 alignItems: "center", 228 gap: "12px", 229 border: "none", 230 background: 231 index === selectedIndex 232 ? "var(--bg-secondary)" 233 : "transparent", 234 cursor: "pointer", 235 }} 236 > 237 <div 238 className="login-suggestion-avatar" 239 style={{ 240 width: 32, 241 height: 32, 242 borderRadius: "50%", 243 overflow: "hidden", 244 background: "var(--bg-tertiary)", 245 }} 246 > 247 {actor.avatar ? ( 248 <img 249 src={actor.avatar} 250 alt="" 251 style={{ 252 width: "100%", 253 height: "100%", 254 objectFit: "cover", 255 }} 256 /> 257 ) : ( 258 <div 259 style={{ 260 display: "flex", 261 alignItems: "center", 262 justifyContent: "center", 263 height: "100%", 264 fontSize: "0.8rem", 265 }} 266 > 267 {(actor.displayName || actor.handle) 268 .substring(0, 2) 269 .toUpperCase()} 270 </div> 271 )} 272 </div> 273 <div 274 className="login-suggestion-info" 275 style={{ display: "flex", flexDirection: "column" }} 276 > 277 <span 278 className="login-suggestion-name" 279 style={{ fontWeight: 600, fontSize: "0.95rem" }} 280 > 281 {actor.displayName || actor.handle} 282 </span> 283 <span 284 className="login-suggestion-handle" 285 style={{ 286 color: "var(--text-secondary)", 287 fontSize: "0.85rem", 288 }} 289 > 290 @{actor.handle} 291 </span> 292 </div> 293 </button> 294 ))} 295 </div> 296 )} 297 </form> 298 299 {error && ( 300 <div className="empty-state"> 301 <div className="empty-state-icon"> 302 <AlertIcon size={32} /> 303 </div> 304 <h3 className="empty-state-title">Error</h3> 305 <p className="empty-state-text">{error}</p> 306 </div> 307 )} 308 309 {searched && !loading && !error && totalItems === 0 && ( 310 <div className="empty-state"> 311 <div className="empty-state-icon"> 312 <SearchIcon size={32} /> 313 </div> 314 <h3 className="empty-state-title">No annotations found</h3> 315 <p className="empty-state-text"> 316 Be the first to annotate this URL! Sign in to add your thoughts. 317 </p> 318 </div> 319 )} 320 321 {searched && totalItems > 0 && ( 322 <> 323 <div className="url-results-header"> 324 <h2 className="feed-title"> 325 {totalItems} item{totalItems !== 1 ? "s" : ""} 326 </h2> 327 <div className="feed-filters"> 328 <button 329 className={`filter-tab ${activeTab === "all" ? "active" : ""}`} 330 onClick={() => setActiveTab("all")} 331 > 332 All ({totalItems}) 333 </button> 334 <button 335 className={`filter-tab ${activeTab === "annotations" ? "active" : ""}`} 336 onClick={() => setActiveTab("annotations")} 337 > 338 Annotations ({annotations.length}) 339 </button> 340 <button 341 className={`filter-tab ${activeTab === "highlights" ? "active" : ""}`} 342 onClick={() => setActiveTab("highlights")} 343 > 344 Highlights ({highlights.length}) 345 </button> 346 </div> 347 </div> 348 349 {user && myItemsCount > 0 && ( 350 <div className="share-notes-banner"> 351 <div className="share-notes-info"> 352 <ExternalLink size={16} /> 353 <span> 354 You have {myItemsCount} note{myItemsCount !== 1 ? "s" : ""} on 355 this page 356 </span> 357 </div> 358 <div className="share-notes-actions"> 359 <Link 360 to={`/${user.handle}/url/${encodeURIComponent(url)}`} 361 className="btn btn-ghost btn-sm" 362 > 363 View 364 </Link> 365 <button 366 onClick={handleCopyShareLink} 367 className="btn btn-primary btn-sm" 368 > 369 {copied ? ( 370 <> 371 <Check size={14} /> Copied! 372 </> 373 ) : ( 374 <> 375 <Copy size={14} /> Copy Share Link 376 </> 377 )} 378 </button> 379 </div> 380 </div> 381 )} 382 383 <div className="feed-container"> 384 <div className="feed">{renderResults()}</div> 385 </div> 386 </> 387 )} 388 </div> 389 ); 390}