Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useRef, useState } from "react";
2import { Search, Coffee } from "lucide-react";
3import {
4 getTrendingTags,
5 searchActors,
6 type ActorSearchItem,
7 type Tag,
8} from "../../api/client";
9import { Avatar } from "../ui";
10
11function looksLikeUrl(query: string): boolean {
12 const q = query.trim().toLowerCase();
13 return (
14 q.startsWith("http://") ||
15 q.startsWith("https://") ||
16 /\.(com|org|net|io|dev|me|co|app|xyz|edu|gov)\b/.test(q)
17 );
18}
19
20interface RightSidebarProps {
21 onNavigate?: (path: string) => void;
22}
23
24export default function RightSidebar({ onNavigate }: RightSidebarProps) {
25 const navigate = (path: string) => {
26 if (onNavigate) onNavigate(path);
27 else window.location.href = path;
28 };
29 const [tags, setTags] = useState<Tag[]>([]);
30 const [browser] = useState<"chrome" | "firefox" | "edge" | "other">(() => {
31 if (typeof navigator === "undefined") return "other";
32 const ua = navigator.userAgent;
33 if (/Edg\//i.test(ua)) return "edge";
34 if (/Firefox/i.test(ua)) return "firefox";
35 if (/Chrome/i.test(ua)) return "chrome";
36 return "other";
37 });
38 const [searchQuery, setSearchQuery] = useState("");
39 const [suggestions, setSuggestions] = useState<ActorSearchItem[]>([]);
40 const [showSuggestions, setShowSuggestions] = useState(false);
41 const [selectedIndex, setSelectedIndex] = useState(-1);
42
43 const inputRef = useRef<HTMLInputElement>(null);
44 const suggestionsRef = useRef<HTMLDivElement>(null);
45 const isSelectionRef = useRef(false);
46 const latestQueryRef = useRef(searchQuery);
47
48 useEffect(() => {
49 latestQueryRef.current = searchQuery;
50
51 if (searchQuery.length < 3 || looksLikeUrl(searchQuery)) {
52 return;
53 }
54
55 if (isSelectionRef.current) {
56 isSelectionRef.current = false;
57 return;
58 }
59
60 const capturedQuery = searchQuery;
61 const timer = setTimeout(async () => {
62 try {
63 const data = await searchActors(capturedQuery);
64 if (capturedQuery !== latestQueryRef.current) return;
65 setSuggestions(data.actors || []);
66 setShowSuggestions((data.actors || []).length > 0);
67 setSelectedIndex(-1);
68 } catch (e) {
69 console.error("Search failed:", e);
70 }
71 }, 300);
72
73 return () => clearTimeout(timer);
74 }, [searchQuery]);
75
76 useEffect(() => {
77 const handleClickOutside = (e: MouseEvent) => {
78 if (
79 suggestionsRef.current &&
80 !suggestionsRef.current.contains(e.target as Node) &&
81 inputRef.current &&
82 !inputRef.current.contains(e.target as Node)
83 ) {
84 setShowSuggestions(false);
85 }
86 };
87 document.addEventListener("mousedown", handleClickOutside);
88 return () => document.removeEventListener("mousedown", handleClickOutside);
89 }, []);
90
91 const selectSuggestion = (actor: ActorSearchItem) => {
92 isSelectionRef.current = true;
93 setSearchQuery("");
94 setSuggestions([]);
95 setShowSuggestions(false);
96 navigate(`/profile/${encodeURIComponent(actor.handle)}`);
97 };
98
99 const handleKeyDown = (e: React.KeyboardEvent) => {
100 if (showSuggestions && suggestions.length > 0) {
101 if (e.key === "ArrowDown") {
102 e.preventDefault();
103 setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
104 return;
105 } else if (e.key === "ArrowUp") {
106 e.preventDefault();
107 setSelectedIndex((prev) => Math.max(prev - 1, -1));
108 return;
109 } else if (e.key === "Enter" && selectedIndex >= 0) {
110 e.preventDefault();
111 selectSuggestion(suggestions[selectedIndex]);
112 return;
113 } else if (e.key === "Escape") {
114 setShowSuggestions(false);
115 return;
116 }
117 }
118
119 if (e.key === "Enter" && searchQuery.trim()) {
120 const q = searchQuery.trim();
121 if (looksLikeUrl(q)) {
122 navigate(`/url/${encodeURIComponent(q)}`);
123 } else if (q.includes(".")) {
124 navigate(`/profile/${encodeURIComponent(q)}`);
125 } else {
126 navigate(`/search?q=${encodeURIComponent(q)}`);
127 }
128 setSearchQuery("");
129 setSuggestions([]);
130 setShowSuggestions(false);
131 }
132 };
133
134 useEffect(() => {
135 getTrendingTags(10).then(setTags);
136 }, []);
137
138 const extensionLink =
139 browser === "firefox"
140 ? "https://addons.mozilla.org/en-US/firefox/addon/margin/"
141 : browser === "edge"
142 ? "https://microsoftedge.microsoft.com/addons/detail/margin/nfjnmllpdgcdnhmmggjihjbidmeadddn"
143 : "https://chromewebstore.google.com/detail/margin/cgpmbiiagnehkikhcbnhiagfomajncpa";
144
145 return (
146 <aside className="hidden xl:block w-[320px] shrink-0 sticky top-0 h-screen overflow-y-auto px-6 py-6">
147 <div className="space-y-5">
148 <div className="relative">
149 <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
150 <Search
151 className="text-surface-400 dark:text-surface-500"
152 size={15}
153 />
154 </div>
155 <input
156 ref={inputRef}
157 type="text"
158 value={searchQuery}
159 onChange={(e) => {
160 setSearchQuery(e.target.value);
161 if (e.target.value.length < 3) {
162 setSuggestions([]);
163 setShowSuggestions(false);
164 }
165 }}
166 onKeyDown={handleKeyDown}
167 onFocus={() =>
168 searchQuery.length >= 3 &&
169 suggestions.length > 0 &&
170 setShowSuggestions(true)
171 }
172 placeholder="Search people, tags, URLs..."
173 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-transparent focus:border-surface-200 dark:focus:border-surface-700"
174 />
175
176 {showSuggestions && suggestions.length > 0 && (
177 <div
178 ref={suggestionsRef}
179 className="absolute top-[calc(100%+6px)] left-0 right-0 bg-white dark:bg-surface-900 border border-surface-200 dark:border-surface-700 rounded-xl shadow-xl overflow-hidden z-50 animate-fade-in max-h-[280px] overflow-y-auto"
180 >
181 {suggestions.map((actor, index) => (
182 <button
183 key={actor.did}
184 type="button"
185 className={`w-full flex items-center gap-3 px-3.5 py-2.5 border-b border-surface-100 dark:border-surface-800 last:border-0 hover:bg-surface-50 dark:hover:bg-surface-800 transition-colors text-left ${index === selectedIndex ? "bg-surface-50 dark:bg-surface-800" : ""}`}
186 onClick={() => selectSuggestion(actor)}
187 >
188 <Avatar src={actor.avatar} size="sm" />
189 <div className="min-w-0 flex-1">
190 <div className="font-semibold text-surface-900 dark:text-white truncate text-sm leading-tight">
191 {actor.displayName || actor.handle}
192 </div>
193 <div className="text-surface-500 dark:text-surface-400 text-xs truncate">
194 @{actor.handle}
195 </div>
196 </div>
197 </button>
198 ))}
199 </div>
200 )}
201 </div>
202
203 <div className="rounded-xl p-4 bg-gradient-to-br from-primary-50 to-primary-100/50 dark:from-primary-950/30 dark:to-primary-900/10 border border-primary-200/40 dark:border-primary-800/30">
204 <h3 className="font-semibold text-sm mb-1 text-surface-900 dark:text-white">
205 Get the Extension
206 </h3>
207 <p className="text-surface-500 dark:text-surface-400 text-xs mb-3 leading-relaxed">
208 Highlight, annotate, and bookmark from any page.
209 </p>
210 <a
211 href={extensionLink}
212 target="_blank"
213 rel="noopener noreferrer"
214 className="flex items-center justify-center w-full px-4 py-2 bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-400 text-white dark:text-white rounded-lg transition-colors text-sm font-medium"
215 >
216 Download for{" "}
217 {browser === "firefox"
218 ? "Firefox"
219 : browser === "edge"
220 ? "Edge"
221 : "Chrome"}
222 </a>
223 </div>
224
225 <div>
226 <h3 className="font-semibold text-sm px-1 mb-3 text-surface-900 dark:text-white tracking-tight">
227 Trending
228 </h3>
229 {tags.length > 0 ? (
230 <div className="flex flex-col">
231 {tags.map((t) => (
232 <a
233 key={t.tag}
234 href={`/home?tag=${encodeURIComponent(t.tag)}`}
235 className="px-2 py-2.5 hover:bg-surface-100 dark:hover:bg-surface-800 rounded-lg transition-colors group"
236 >
237 <div className="font-semibold text-sm text-surface-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
238 #{t.tag}
239 </div>
240 <div className="text-xs text-surface-400 dark:text-surface-500 mt-0.5">
241 {t.count} {t.count === 1 ? "post" : "posts"}
242 </div>
243 </a>
244 ))}
245 </div>
246 ) : (
247 <div className="px-2">
248 <p className="text-sm text-surface-400 dark:text-surface-500">
249 Nothing trending right now.
250 </p>
251 </div>
252 )}
253 </div>
254
255 <div className="px-1 pt-2">
256 <div className="flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-surface-400 dark:text-surface-500 leading-relaxed">
257 <a
258 href="/about"
259 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
260 >
261 About
262 </a>
263 <a
264 href="/privacy"
265 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
266 >
267 Privacy
268 </a>
269 <a
270 href="/terms"
271 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
272 >
273 Terms
274 </a>
275 <a
276 href="https://github.com/margin-at/margin"
277 target="_blank"
278 rel="noreferrer"
279 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
280 >
281 GitHub
282 </a>
283 <a
284 href="https://tangled.org/margin.at/margin"
285 target="_blank"
286 rel="noreferrer"
287 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
288 >
289 Tangled
290 </a>
291 <a
292 href="https://discord.gg/ZQbkGqwzBH"
293 target="_blank"
294 rel="noreferrer"
295 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
296 >
297 Discord
298 </a>
299 <a
300 href="https://matrix.to/#/#margin:blep.cat"
301 target="_blank"
302 rel="noreferrer"
303 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
304 >
305 Matrix
306 </a>
307 <a
308 href="https://stt.gg/wHnM6e3h"
309 target="_blank"
310 rel="noreferrer"
311 className="hover:underline hover:text-surface-600 dark:hover:text-surface-300"
312 >
313 Stoat
314 </a>
315 <a
316 href="https://ko-fi.com/scan"
317 target="_blank"
318 rel="noopener noreferrer"
319 className="inline-flex items-center gap-1 text-[12px] text-surface-400 dark:text-surface-500 hover:text-[#FF5E5B] dark:hover:text-[#FF5E5B] transition-colors"
320 >
321 <Coffee size={12} className="shrink-0" />
322 Support on Ko-fi
323 </a>
324 <span>© 2026 Margin</span>
325 </div>
326 </div>
327 </div>
328 </aside>
329 );
330}