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

Introducing Margin Search

+546 -2
+41
backend/internal/api/handler.go
··· 79 79 r.Get("/users/{did}/tags", h.HandleGetUserTags) 80 80 81 81 r.Get("/trending-tags", h.HandleGetTrendingTags) 82 + r.Get("/search", h.Search) 82 83 83 84 r.Get("/replies", h.GetReplies) 84 85 r.Get("/likes", h.GetLikeCount) ··· 1475 1476 } 1476 1477 return result 1477 1478 } 1479 + 1480 + func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { 1481 + query := r.URL.Query().Get("q") 1482 + if query == "" { 1483 + http.Error(w, "q parameter required", http.StatusBadRequest) 1484 + return 1485 + } 1486 + 1487 + creator := r.URL.Query().Get("creator") 1488 + limit := parseIntParam(r, "limit", 50) 1489 + offset := parseIntParam(r, "offset", 0) 1490 + viewerDID := h.getViewerDID(r) 1491 + 1492 + annotations, _ := h.db.SearchAnnotations(query, creator, limit, offset) 1493 + highlights, _ := h.db.SearchHighlights(query, creator, limit, offset) 1494 + bookmarks, _ := h.db.SearchBookmarks(query, creator, limit, offset) 1495 + 1496 + hydratedAnnotations, _ := hydrateAnnotations(h.db, annotations, viewerDID) 1497 + hydratedHighlights, _ := hydrateHighlights(h.db, highlights, viewerDID) 1498 + hydratedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 1499 + 1500 + var feed []interface{} 1501 + for _, a := range hydratedAnnotations { 1502 + feed = append(feed, a) 1503 + } 1504 + for _, hl := range hydratedHighlights { 1505 + feed = append(feed, hl) 1506 + } 1507 + for _, b := range hydratedBookmarks { 1508 + feed = append(feed, b) 1509 + } 1510 + 1511 + sortFeed(feed) 1512 + 1513 + w.Header().Set("Content-Type", "application/json") 1514 + json.NewEncoder(w).Encode(map[string]interface{}{ 1515 + "items": feed, 1516 + "fetchedCount": len(feed), 1517 + }) 1518 + }
+125
backend/internal/db/queries_search.go
··· 1 + package db 2 + 3 + func (db *DB) SearchAnnotations(query string, authorDID string, limit, offset int) ([]Annotation, error) { 4 + pattern := "%" + query + "%" 5 + 6 + var baseQuery string 7 + var args []interface{} 8 + 9 + if authorDID != "" { 10 + baseQuery = db.Rebind(` 11 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 12 + FROM annotations 13 + WHERE author_did = ? 14 + AND (body_value LIKE ? OR target_source LIKE ? OR target_title LIKE ? OR tags_json LIKE ? OR selector_json LIKE ?) 15 + ORDER BY created_at DESC 16 + LIMIT ? OFFSET ? 17 + `) 18 + args = []interface{}{authorDID, pattern, pattern, pattern, pattern, pattern, limit, offset} 19 + } else { 20 + baseQuery = db.Rebind(` 21 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 22 + FROM annotations 23 + WHERE body_value LIKE ? OR target_source LIKE ? OR target_title LIKE ? OR tags_json LIKE ? OR selector_json LIKE ? 24 + ORDER BY created_at DESC 25 + LIMIT ? OFFSET ? 26 + `) 27 + args = []interface{}{pattern, pattern, pattern, pattern, pattern, limit, offset} 28 + } 29 + 30 + rows, err := db.Query(baseQuery, args...) 31 + if err != nil { 32 + return nil, err 33 + } 34 + defer rows.Close() 35 + 36 + return scanAnnotations(rows) 37 + } 38 + 39 + func (db *DB) SearchHighlights(query string, authorDID string, limit, offset int) ([]Highlight, error) { 40 + pattern := "%" + query + "%" 41 + 42 + var baseQuery string 43 + var args []interface{} 44 + 45 + if authorDID != "" { 46 + baseQuery = db.Rebind(` 47 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 48 + FROM highlights 49 + WHERE author_did = ? 50 + AND (target_source LIKE ? OR target_title LIKE ? OR selector_json LIKE ? OR tags_json LIKE ?) 51 + ORDER BY created_at DESC 52 + LIMIT ? OFFSET ? 53 + `) 54 + args = []interface{}{authorDID, pattern, pattern, pattern, pattern, limit, offset} 55 + } else { 56 + baseQuery = db.Rebind(` 57 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 58 + FROM highlights 59 + WHERE target_source LIKE ? OR target_title LIKE ? OR selector_json LIKE ? OR tags_json LIKE ? 60 + ORDER BY created_at DESC 61 + LIMIT ? OFFSET ? 62 + `) 63 + args = []interface{}{pattern, pattern, pattern, pattern, limit, offset} 64 + } 65 + 66 + rows, err := db.Query(baseQuery, args...) 67 + if err != nil { 68 + return nil, err 69 + } 70 + defer rows.Close() 71 + 72 + var highlights []Highlight 73 + for rows.Next() { 74 + var h Highlight 75 + if err := rows.Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID); err != nil { 76 + return nil, err 77 + } 78 + highlights = append(highlights, h) 79 + } 80 + return highlights, nil 81 + } 82 + 83 + func (db *DB) SearchBookmarks(query string, authorDID string, limit, offset int) ([]Bookmark, error) { 84 + pattern := "%" + query + "%" 85 + 86 + var baseQuery string 87 + var args []interface{} 88 + 89 + if authorDID != "" { 90 + baseQuery = db.Rebind(` 91 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 92 + FROM bookmarks 93 + WHERE author_did = ? 94 + AND (source LIKE ? OR title LIKE ? OR description LIKE ? OR tags_json LIKE ?) 95 + ORDER BY created_at DESC 96 + LIMIT ? OFFSET ? 97 + `) 98 + args = []interface{}{authorDID, pattern, pattern, pattern, pattern, limit, offset} 99 + } else { 100 + baseQuery = db.Rebind(` 101 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 102 + FROM bookmarks 103 + WHERE source LIKE ? OR title LIKE ? OR description LIKE ? OR tags_json LIKE ? 104 + ORDER BY created_at DESC 105 + LIMIT ? OFFSET ? 106 + `) 107 + args = []interface{}{pattern, pattern, pattern, pattern, limit, offset} 108 + } 109 + 110 + rows, err := db.Query(baseQuery, args...) 111 + if err != nil { 112 + return nil, err 113 + } 114 + defer rows.Close() 115 + 116 + var bookmarks []Bookmark 117 + for rows.Next() { 118 + var b Bookmark 119 + if err := rows.Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID); err != nil { 120 + return nil, err 121 + } 122 + bookmarks = append(bookmarks, b) 123 + } 124 + return bookmarks, nil 125 + }
+10
web/src/App.tsx
··· 22 22 } from "./routes/wrappers"; 23 23 import About from "./views/About"; 24 24 import AdminModeration from "./views/core/AdminModeration"; 25 + import Search from "./views/core/Search"; 25 26 26 27 function RootRoute() { 27 28 const user = useStore($user); ··· 56 57 } 57 58 /> 58 59 <Route path="/my-feed" element={<Navigate to="/home" replace />} /> 60 + 61 + <Route 62 + path="/search" 63 + element={ 64 + <AppLayout> 65 + <Search /> 66 + </AppLayout> 67 + } 68 + /> 59 69 60 70 <Route 61 71 path="/annotations"
+28
web/src/api/client.ts
··· 244 244 }; 245 245 } 246 246 247 + export async function searchItems( 248 + query: string, 249 + options: { creator?: string; limit?: number; offset?: number } = {}, 250 + ): Promise<FeedResponse> { 251 + const params = new URLSearchParams(); 252 + params.append("q", query); 253 + if (options.creator) params.append("creator", options.creator); 254 + if (options.limit) params.append("limit", options.limit.toString()); 255 + if (options.offset) params.append("offset", options.offset.toString()); 256 + 257 + try { 258 + const res = await apiRequest(`/api/search?${params.toString()}`, { 259 + skipAuthRedirect: true, 260 + }); 261 + if (!res.ok) throw new Error("Search failed"); 262 + const data = await res.json(); 263 + const items: AnnotationItem[] = (data.items || []).map(normalizeItem); 264 + return { 265 + items, 266 + hasMore: items.length >= (options.limit || 50), 267 + fetchedCount: items.length, 268 + }; 269 + } catch (e) { 270 + console.error("Search error:", e); 271 + return { items: [], hasMore: false, fetchedCount: 0 }; 272 + } 273 + } 274 + 247 275 export async function getFeed({ 248 276 source, 249 277 type = "all",
+4 -2
web/src/components/navigation/RightSidebar.tsx
··· 120 120 const q = searchQuery.trim(); 121 121 if (looksLikeUrl(q)) { 122 122 navigate(`/url/${encodeURIComponent(q)}`); 123 - } else { 123 + } else if (q.includes(".")) { 124 124 navigate(`/profile/${encodeURIComponent(q)}`); 125 + } else { 126 + navigate(`/search?q=${encodeURIComponent(q)}`); 125 127 } 126 128 setSearchQuery(""); 127 129 setSuggestions([]); ··· 176 178 suggestions.length > 0 && 177 179 setShowSuggestions(true) 178 180 } 179 - placeholder="Search users or URLs..." 181 + placeholder="Search..." 180 182 className="w-full bg-surface-100 dark:bg-surface-800/80 rounded-lg pl-9 pr-4 py-2 text-sm text-surface-900 dark:text-white placeholder:text-surface-400 dark:placeholder:text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:bg-white dark:focus:bg-surface-800 transition-all border border-surface-200/60 dark:border-surface-700/60" 181 183 /> 182 184
+338
web/src/views/core/Search.tsx
··· 1 + import React, { useState, useEffect, useCallback, useRef } from "react"; 2 + import { useSearchParams } from "react-router-dom"; 3 + import { 4 + Search as SearchIcon, 5 + Loader2, 6 + SlidersHorizontal, 7 + MessageSquareText, 8 + Highlighter, 9 + Bookmark, 10 + } from "lucide-react"; 11 + import { clsx } from "clsx"; 12 + import { useStore } from "@nanostores/react"; 13 + import { searchItems } from "../../api/client"; 14 + import type { AnnotationItem } from "../../types"; 15 + import Card from "../../components/common/Card"; 16 + import { EmptyState } from "../../components/ui"; 17 + import LayoutToggle from "../../components/ui/LayoutToggle"; 18 + import { $user } from "../../store/auth"; 19 + import { $feedLayout } from "../../store/feedLayout"; 20 + 21 + export default function Search() { 22 + const [searchParams, setSearchParams] = useSearchParams(); 23 + const initialQuery = searchParams.get("q") || ""; 24 + const user = useStore($user); 25 + const layout = useStore($feedLayout); 26 + 27 + const [query, setQuery] = useState(initialQuery); 28 + const [results, setResults] = useState<AnnotationItem[]>([]); 29 + const [loading, setLoading] = useState(false); 30 + const [hasMore, setHasMore] = useState(false); 31 + const [offset, setOffset] = useState(0); 32 + const [myItemsOnly, setMyItemsOnly] = useState(false); 33 + const [activeFilter, setActiveFilter] = useState<string | undefined>( 34 + undefined, 35 + ); 36 + const [platform, setPlatform] = useState<"all" | "margin" | "semble">("all"); 37 + const inputRef = useRef<HTMLInputElement>(null); 38 + const myItemsRef = useRef(myItemsOnly); 39 + const fetchIdRef = useRef(0); 40 + 41 + useEffect(() => { 42 + myItemsRef.current = myItemsOnly; 43 + }, [myItemsOnly]); 44 + 45 + const filters = [ 46 + { id: "all", label: "All", icon: null }, 47 + { id: "commenting", label: "Annotations", icon: MessageSquareText }, 48 + { id: "highlighting", label: "Highlights", icon: Highlighter }, 49 + { id: "bookmarking", label: "Bookmarks", icon: Bookmark }, 50 + ]; 51 + 52 + const doSearch = useCallback( 53 + async (q: string, newOffset = 0, append = false) => { 54 + if (!q.trim()) { 55 + setResults([]); 56 + return; 57 + } 58 + const id = ++fetchIdRef.current; 59 + setLoading(true); 60 + const data = await searchItems(q.trim(), { 61 + creator: myItemsRef.current && user ? user.did : undefined, 62 + limit: 30, 63 + offset: newOffset, 64 + }); 65 + if (id !== fetchIdRef.current) return; 66 + if (append) { 67 + setResults((prev) => [...prev, ...data.items]); 68 + } else { 69 + setResults(data.items); 70 + } 71 + setHasMore(data.hasMore); 72 + setOffset(newOffset + data.items.length); 73 + setLoading(false); 74 + }, 75 + [user], 76 + ); 77 + 78 + useEffect(() => { 79 + if (initialQuery) { 80 + // eslint-disable-next-line react-hooks/set-state-in-effect 81 + doSearch(initialQuery); 82 + } 83 + }, [initialQuery, doSearch]); 84 + 85 + const handleSubmit = (e: React.FormEvent) => { 86 + e.preventDefault(); 87 + if (query.trim()) { 88 + setSearchParams({ q: query.trim() }); 89 + doSearch(query.trim()); 90 + } 91 + }; 92 + 93 + const handleDelete = (uri: string) => { 94 + setResults((prev) => prev.filter((item) => item.uri !== uri)); 95 + }; 96 + 97 + const handleFilterChange = (id: string) => { 98 + setActiveFilter(id === "all" ? undefined : id); 99 + }; 100 + 101 + const filteredResults = results.filter((item) => { 102 + if (activeFilter && item.motivation !== activeFilter) return false; 103 + if (platform === "margin" && item.uri?.includes("network.cosmik")) 104 + return false; 105 + if (platform === "semble" && !item.uri?.includes("network.cosmik")) 106 + return false; 107 + return true; 108 + }); 109 + 110 + return ( 111 + <div className="mx-auto max-w-2xl xl:max-w-none"> 112 + <form onSubmit={handleSubmit} className="mb-4"> 113 + <div className="relative"> 114 + <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> 115 + <SearchIcon 116 + className="text-surface-400 dark:text-surface-500" 117 + size={18} 118 + /> 119 + </div> 120 + <input 121 + ref={inputRef} 122 + type="text" 123 + value={query} 124 + onChange={(e) => setQuery(e.target.value)} 125 + placeholder="Search annotations, highlights, bookmarks..." 126 + autoFocus 127 + className="w-full pl-11 pr-4 py-3 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-xl text-sm focus:outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-400/20 placeholder:text-surface-400" 128 + /> 129 + </div> 130 + </form> 131 + 132 + {initialQuery && ( 133 + <div className="sticky top-0 z-10 bg-white/95 dark:bg-surface-800/95 backdrop-blur-sm pb-3 mb-2 -mx-1 px-1 pt-1 space-y-2"> 134 + <div className="flex items-center gap-1.5 flex-wrap"> 135 + {filters.map((f) => { 136 + const isActive = 137 + f.id === "all" ? !activeFilter : activeFilter === f.id; 138 + return ( 139 + <button 140 + key={f.id} 141 + onClick={() => handleFilterChange(f.id)} 142 + className={clsx( 143 + "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 144 + isActive 145 + ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 146 + : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 147 + )} 148 + > 149 + {f.icon && <f.icon size={12} />} 150 + {f.label} 151 + </button> 152 + ); 153 + })} 154 + 155 + {user && ( 156 + <button 157 + type="button" 158 + onClick={() => { 159 + const next = !myItemsOnly; 160 + setMyItemsOnly(next); 161 + myItemsRef.current = next; 162 + if (initialQuery) { 163 + doSearch(initialQuery); 164 + } 165 + }} 166 + className={clsx( 167 + "inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full border transition-all", 168 + myItemsOnly 169 + ? "bg-primary-600 dark:bg-primary-500 text-white border-transparent shadow-sm" 170 + : "bg-white dark:bg-surface-900 text-surface-500 dark:text-surface-400 border-surface-200 dark:border-surface-700 hover:border-primary-300 dark:hover:border-primary-700 hover:text-primary-600 dark:hover:text-primary-400", 171 + )} 172 + > 173 + <SlidersHorizontal size={12} /> 174 + Mine 175 + </button> 176 + )} 177 + 178 + <div className="ml-auto flex items-center gap-1.5"> 179 + <div className="inline-flex items-center rounded-lg border border-surface-200 dark:border-surface-700 p-0.5 bg-surface-50 dark:bg-surface-800/60 hidden sm:inline-flex"> 180 + <button 181 + onClick={() => 182 + setPlatform(platform === "margin" ? "all" : "margin") 183 + } 184 + title="Margin only" 185 + className={clsx( 186 + "flex items-center justify-center w-7 h-7 rounded-md transition-all group", 187 + platform === "margin" 188 + ? "bg-white dark:bg-surface-700 shadow-sm" 189 + : "hover:bg-surface-100 dark:hover:bg-surface-700/50", 190 + )} 191 + > 192 + {platform === "margin" ? ( 193 + <img 194 + src="/logo.svg" 195 + alt="Margin" 196 + className="w-4 h-4 transition-all" 197 + /> 198 + ) : ( 199 + <div 200 + className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:bg-surface-600 dark:group-hover:bg-surface-300 transition-colors" 201 + style={{ 202 + maskImage: "url(/logo.svg)", 203 + WebkitMaskImage: "url(/logo.svg)", 204 + maskSize: "contain", 205 + WebkitMaskSize: "contain", 206 + maskRepeat: "no-repeat", 207 + WebkitMaskRepeat: "no-repeat", 208 + maskPosition: "center", 209 + WebkitMaskPosition: "center", 210 + }} 211 + /> 212 + )} 213 + </button> 214 + <button 215 + onClick={() => 216 + setPlatform(platform === "semble" ? "all" : "semble") 217 + } 218 + title="Semble only" 219 + className={clsx( 220 + "flex items-center justify-center w-7 h-7 rounded-md transition-all group", 221 + platform === "semble" 222 + ? "bg-white dark:bg-surface-700 shadow-sm" 223 + : "hover:bg-surface-100 dark:hover:bg-surface-700/50", 224 + )} 225 + > 226 + {platform === "semble" ? ( 227 + <img 228 + src="/semble-logo.svg" 229 + alt="Semble" 230 + className="w-4 h-4 transition-all" 231 + /> 232 + ) : ( 233 + <div 234 + className="w-4 h-4 bg-surface-400 dark:bg-surface-500 group-hover:bg-surface-600 dark:group-hover:bg-surface-300 transition-colors" 235 + style={{ 236 + maskImage: "url(/semble-logo.svg)", 237 + WebkitMaskImage: "url(/semble-logo.svg)", 238 + maskSize: "contain", 239 + WebkitMaskSize: "contain", 240 + maskRepeat: "no-repeat", 241 + WebkitMaskRepeat: "no-repeat", 242 + maskPosition: "center", 243 + WebkitMaskPosition: "center", 244 + }} 245 + /> 246 + )} 247 + </button> 248 + </div> 249 + <LayoutToggle className="hidden sm:inline-flex" /> 250 + </div> 251 + </div> 252 + </div> 253 + )} 254 + 255 + {loading && results.length === 0 && ( 256 + <div className="flex items-center justify-center py-20 animate-fade-in"> 257 + <Loader2 className="animate-spin text-surface-400" size={24} /> 258 + </div> 259 + )} 260 + 261 + {loading && results.length > 0 && ( 262 + <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20"> 263 + <div className="bg-white/90 dark:bg-surface-800/90 shadow-lg rounded-full p-3 backdrop-blur-sm animate-in fade-in zoom-in-95"> 264 + <Loader2 265 + className="animate-spin text-primary-600 dark:text-primary-400" 266 + size={24} 267 + /> 268 + </div> 269 + </div> 270 + )} 271 + 272 + {!loading && initialQuery && filteredResults.length === 0 && ( 273 + <EmptyState 274 + icon={<SearchIcon size={48} />} 275 + title="No results found" 276 + message={`Nothing matched "${initialQuery}". Try different keywords.`} 277 + /> 278 + )} 279 + 280 + {filteredResults.length > 0 && ( 281 + <div 282 + className={clsx( 283 + "transition-opacity duration-200 relative", 284 + loading ? "opacity-40 pointer-events-none" : "opacity-100", 285 + )} 286 + > 287 + <p className="text-xs text-surface-400 dark:text-surface-500 font-medium mb-3 px-1"> 288 + {filteredResults.length} 289 + {hasMore ? "+" : ""} results for &ldquo;{initialQuery}&rdquo; 290 + </p> 291 + 292 + {layout === "mosaic" ? ( 293 + <div className="columns-1 sm:columns-2 gap-3 space-y-3"> 294 + {filteredResults.map((item) => ( 295 + <div key={item.uri} className="break-inside-avoid"> 296 + <Card item={item} onDelete={handleDelete} layout="mosaic" /> 297 + </div> 298 + ))} 299 + </div> 300 + ) : ( 301 + <div className="space-y-3"> 302 + {filteredResults.map((item) => ( 303 + <Card 304 + key={item.uri} 305 + item={item} 306 + onDelete={handleDelete} 307 + layout="list" 308 + /> 309 + ))} 310 + </div> 311 + )} 312 + 313 + {hasMore && ( 314 + <button 315 + onClick={() => doSearch(initialQuery, offset, true)} 316 + disabled={loading} 317 + className="w-full py-3 mt-3 text-sm font-medium text-primary-600 dark:text-primary-400 bg-surface-50 dark:bg-surface-800 rounded-xl hover:bg-surface-100 dark:hover:bg-surface-700 transition-colors disabled:opacity-50" 318 + > 319 + {loading ? ( 320 + <Loader2 className="animate-spin mx-auto" size={16} /> 321 + ) : ( 322 + "Load more" 323 + )} 324 + </button> 325 + )} 326 + </div> 327 + )} 328 + 329 + {!initialQuery && !loading && ( 330 + <EmptyState 331 + icon={<SearchIcon size={48} />} 332 + title="Search your library" 333 + message="Find annotations, highlights, and bookmarks by keyword, URL, or tag." 334 + /> 335 + )} 336 + </div> 337 + ); 338 + }