Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments

support user search in url page

+198 -11
+198 -11
web/src/pages/Url.jsx
··· 1 - import { useState } from "react"; 2 - import { Link } from "react-router-dom"; 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 - import { getByTarget } from "../api/client"; 5 import { useAuth } from "../context/AuthContext"; 6 import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; 7 import { Copy, Check, ExternalLink } from "lucide-react"; 8 9 export default function Url() { 10 const { user } = useAuth(); 11 const [url, setUrl] = useState(""); 12 const [annotations, setAnnotations] = useState([]); 13 const [highlights, setHighlights] = useState([]); ··· 17 const [activeTab, setActiveTab] = useState("all"); 18 const [copied, setCopied] = useState(false); 19 20 const handleSearch = async (e) => { 21 e.preventDefault(); 22 if (!url.trim()) return; 23 24 try { 25 - setLoading(true); 26 - setError(null); 27 - setSearched(true); 28 const data = await getByTarget(url); 29 setAnnotations(data.annotations || []); 30 setHighlights(data.highlights || []); ··· 87 return ( 88 <div className="url-page"> 89 <div className="page-header"> 90 - <h1 className="page-title">Browse by URL</h1> 91 <p className="page-description"> 92 - See annotations and highlights for any webpage 93 </p> 94 </div> 95 96 - <form onSubmit={handleSearch} className="url-input-wrapper"> 97 <div className="url-input-container"> 98 <input 99 - type="url" 100 value={url} 101 onChange={(e) => setUrl(e.target.value)} 102 - placeholder="https://example.com/article" 103 className="url-input" 104 required 105 /> 106 <button type="submit" className="btn btn-primary" disabled={loading}> 107 {loading ? "Searching..." : "Search"} 108 </button> 109 </div> 110 </form> 111 112 {error && (
··· 1 + import { useState, useEffect, useRef } from "react"; 2 + import { Link, useNavigate } from "react-router-dom"; 3 import AnnotationCard, { HighlightCard } from "../components/AnnotationCard"; 4 + import { getByTarget, searchActors } from "../api/client"; 5 import { useAuth } from "../context/AuthContext"; 6 import { PenIcon, AlertIcon, SearchIcon } from "../components/Icons"; 7 import { Copy, Check, ExternalLink } from "lucide-react"; 8 9 export 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([]); ··· 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 || []); ··· 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 && (