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