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

Configure Feed

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

support user search in url page

scanash00 87cca4da 3be7368d

+198 -11
+198 -11
web/src/pages/Url.jsx
··· 1 - import { useState } from "react"; 2 - import { Link } from "react-router-dom"; 1 + import { useState, useEffect, useRef } from "react"; 2 + import { Link, useNavigate } from "react-router-dom"; 3 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 - import { getByTarget } from "../api/client"; 4 + import { getByTarget, searchActors } from "../api/client"; 5 5 import { useAuth } from "../context/AuthContext"; 6 6 import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; 7 7 import { Copy, Check, ExternalLink } from "lucide-react"; 8 8 9 9 export default function Url() { 10 10 const { user } = useAuth(); 11 + const navigate = useNavigate(); 11 12 const [url, setUrl] = useState(""); 12 13 const [annotations, setAnnotations] = useState([]); 13 14 const [highlights, setHighlights] = useState([]); ··· 17 18 const [activeTab, setActiveTab] = useState("all"); 18 19 const [copied, setCopied] = useState(false); 19 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 + 20 82 const handleSearch = async (e) => { 21 83 e.preventDefault(); 22 84 if (!url.trim()) return; 23 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 + 24 104 try { 25 - setLoading(true); 26 - setError(null); 27 - setSearched(true); 28 105 const data = await getByTarget(url); 29 106 setAnnotations(data.annotations || []); 30 107 setHighlights(data.highlights || []); ··· 87 164 return ( 88 165 <div className="url-page"> 89 166 <div className="page-header"> 90 - <h1 className="page-title">Browse by URL</h1> 167 + <h1 className="page-title">Explore</h1> 91 168 <p className="page-description"> 92 - See annotations and highlights for any webpage 169 + Search for a URL to view its context layer, or find a user by their 170 + handle 93 171 </p> 94 172 </div> 95 173 96 - <form onSubmit={handleSearch} className="url-input-wrapper"> 174 + <form 175 + onSubmit={handleSearch} 176 + className="url-input-wrapper" 177 + style={{ position: "relative" }} 178 + > 97 179 <div className="url-input-container"> 98 180 <input 99 - type="url" 181 + ref={inputRef} 182 + type="text" 100 183 value={url} 101 184 onChange={(e) => setUrl(e.target.value)} 102 - placeholder="https://example.com/article" 185 + onKeyDown={handleKeyDown} 186 + placeholder="https://... or handle" 103 187 className="url-input" 188 + autoComplete="off" 104 189 required 105 190 /> 106 191 <button type="submit" className="btn btn-primary" disabled={loading}> 107 192 {loading ? "Searching..." : "Search"} 108 193 </button> 109 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 + )} 110 297 </form> 111 298 112 299 {error && (