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