(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import { useStore } from "@nanostores/react";
2import { Suspense, useEffect, useState } from "react";
3import { I18nextProvider } from "react-i18next";
4import i18n from "../i18n";
5import type { UserProfile } from "../types";
6
7declare global {
8 interface Window {
9 __MARGIN_USER__?: UserProfile | null;
10 }
11}
12
13import {
14 BrowserRouter,
15 Navigate,
16 Route,
17 Routes,
18 useLocation,
19 useNavigate,
20 useParams,
21} from "react-router-dom";
22import { checkSession } from "../api/client";
23
24import MobileNav from "../components/navigation/MobileNav";
25import RightSidebar from "../components/navigation/RightSidebar";
26import Sidebar from "../components/navigation/Sidebar";
27import { $user } from "../store/auth";
28import { analytics } from "../lib/analytics";
29
30import AdminModeration from "./core/AdminModeration";
31import Discover from "./core/Discover";
32import Feed from "./core/Feed";
33import New from "./core/New";
34import Notifications from "./core/Notifications";
35import Search from "./core/Search";
36import Settings from "./core/Settings";
37import Collections from "./collections/Collections";
38import CollectionDetail from "./collections/CollectionDetail";
39import AnnotationDetail from "./content/AnnotationDetail";
40import UrlPage from "./content/UrlPage";
41import UserUrlPage from "./content/UserUrlPage";
42import Profile from "./profile/Profile";
43
44const PAGE_TITLES: Record<string, string> = {
45 "/home": "Home — Margin",
46 "/bookmarks": "Bookmarks — Margin",
47 "/highlights": "Highlights — Margin",
48 "/annotations": "Annotations — Margin",
49 "/discover": "Discover — Margin",
50 "/search": "Search — Margin",
51 "/notifications": "Notifications — Margin",
52 "/new": "New Annotation — Margin",
53 "/settings": "Settings — Margin",
54 "/collections": "Collections — Margin",
55 "/admin/moderation": "Admin — Margin",
56};
57
58function AuthGuard({ children }: { children: React.ReactNode }) {
59 const user = useStore($user);
60 const [checked, setChecked] = useState(() => "__MARGIN_USER__" in window);
61
62 useEffect(() => {
63 if (!checked) {
64 const unsub = $user.subscribe(() => setChecked(true));
65 const t = setTimeout(() => setChecked(true), 3000);
66 return () => {
67 unsub();
68 clearTimeout(t);
69 };
70 }
71 }, [checked]);
72
73 useEffect(() => {
74 if (checked && !user) {
75 window.location.href = "/login";
76 }
77 }, [checked, user]);
78
79 if (!checked || !user) return null;
80 return <>{children}</>;
81}
82
83function CollectionDetailRoute() {
84 const { handle, rkey } = useParams<{ handle: string; rkey: string }>();
85 return <CollectionDetail handle={handle} rkey={rkey} />;
86}
87
88function AnnotationDetailRoute() {
89 const { handle, rkey, type } = useParams<{
90 handle: string;
91 rkey: string;
92 type: string;
93 }>();
94 return <AnnotationDetail handle={handle} rkey={rkey} type={type} />;
95}
96
97function AtAnnotationRoute() {
98 const { did, rkey } = useParams<{ did: string; rkey: string }>();
99 return <AnnotationDetail did={did} rkey={rkey} />;
100}
101
102function AtCollectionAnnotationRoute() {
103 const { did, collection, rkey } = useParams<{
104 did: string;
105 collection: string;
106 rkey: string;
107 }>();
108 return <AnnotationDetail did={did} collection={collection} rkey={rkey} />;
109}
110
111function UriAnnotationRoute() {
112 const { uri } = useParams<{ uri: string }>();
113 return <AnnotationDetail uri={uri ? decodeURIComponent(uri) : undefined} />;
114}
115
116function ProfileRoute() {
117 const { did } = useParams<{ did: string }>();
118 if (!did) return <Navigate to="/home" replace />;
119 return <Profile did={did} />;
120}
121
122function UrlRoute() {
123 const params = useParams();
124 const urlPath = params["*"];
125 return <UrlPage urlPath={urlPath} />;
126}
127
128function UserUrlRoute() {
129 const params = useParams();
130 return <UserUrlPage handle={params.handle} urlPath={params["*"]} />;
131}
132
133function AppLayout() {
134 const location = useLocation();
135 const navigate = useNavigate();
136 const searchParams = new URLSearchParams(location.search);
137
138 useEffect(() => {
139 document.title = PAGE_TITLES[location.pathname] ?? "Margin";
140 }, [location.pathname]);
141
142 useEffect(() => {
143 if (searchParams.get("logged_in") !== "true") return;
144 const user = $user.get();
145 analytics.capture("login_success", {
146 handle: user?.handle ?? "",
147 pds: undefined,
148 });
149 const url = new URL(window.location.href);
150 url.searchParams.delete("logged_in");
151 window.history.replaceState({}, "", url.toString());
152 // eslint-disable-next-line react-hooks/exhaustive-deps
153 }, [location.search]);
154
155 useEffect(() => {
156 const SERVER_PATHS = [
157 "/login",
158 "/about",
159 "/privacy",
160 "/terms",
161 "/brand",
162 "/auth/",
163 "/api/",
164 "/og-image",
165 ];
166 const handleClick = (e: MouseEvent) => {
167 const a = (e.target as Element).closest("a");
168 if (!a) return;
169 if (a.hasAttribute("target") || a.hasAttribute("download")) return;
170 const href = a.getAttribute("href");
171 if (!href || !href.startsWith("/")) return;
172 if (href === "/" || SERVER_PATHS.some((p: string) => href.startsWith(p)))
173 return;
174 e.preventDefault();
175 navigate(href);
176 };
177 document.addEventListener("click", handleClick);
178 return () => document.removeEventListener("click", handleClick);
179 }, [navigate]);
180
181 return (
182 <div className="min-h-screen bg-surface-100 dark:bg-surface-900 flex">
183 <Sidebar currentPath={location.pathname} onNavigate={navigate} />
184
185 <div className="flex-1 min-w-0 transition-all duration-200">
186 <div className="flex w-full max-w-[1800px] mx-auto">
187 <main className="flex-1 w-full min-w-0 p-2 md:py-3 md:px-0">
188 <div className="bg-white dark:bg-surface-800 rounded-2xl min-h-[calc(100vh-16px)] md:min-h-[calc(100vh-24px)] py-6 px-4 md:px-6 lg:px-8 pb-28 md:pb-6">
189 <Routes>
190 <Route
191 path="/home"
192 element={
193 <Feed
194 key="home"
195 initialType="all"
196 initialTag={searchParams.get("tag") ?? undefined}
197 />
198 }
199 />
200 <Route
201 path="/bookmarks"
202 element={
203 <Feed
204 key="bookmarks"
205 initialType="all"
206 motivation="bookmarking"
207 showTabs={false}
208 />
209 }
210 />
211 <Route
212 path="/highlights"
213 element={
214 <Feed
215 key="highlights"
216 initialType="all"
217 motivation="highlighting"
218 showTabs={false}
219 />
220 }
221 />
222 <Route
223 path="/annotations"
224 element={
225 <Feed
226 key="annotations"
227 initialType="all"
228 motivation="commenting"
229 showTabs={false}
230 />
231 }
232 />
233 <Route path="/discover" element={<Discover />} />
234 <Route
235 path="/search"
236 element={
237 <Search
238 key={searchParams.get("q") ?? ""}
239 initialQuery={searchParams.get("q") ?? undefined}
240 />
241 }
242 />
243 <Route
244 path="/notifications"
245 element={
246 <AuthGuard>
247 <Notifications />
248 </AuthGuard>
249 }
250 />
251 <Route
252 path="/new"
253 element={
254 <AuthGuard>
255 <New
256 initialUrl={searchParams.get("url") ?? undefined}
257 initialSelectorJson={
258 searchParams.get("selector") ?? undefined
259 }
260 initialQuote={searchParams.get("quote") ?? undefined}
261 />
262 </AuthGuard>
263 }
264 />
265 <Route path="/settings" element={<Settings />} />
266 <Route
267 path="/admin/moderation"
268 element={
269 <AuthGuard>
270 <AdminModeration />
271 </AuthGuard>
272 }
273 />
274 <Route path="/collections" element={<Collections />} />
275 <Route
276 path="/collections/:rkey"
277 element={<CollectionDetail />}
278 />
279 <Route
280 path="/:handle/collection/:rkey"
281 element={<CollectionDetailRoute />}
282 />
283 <Route
284 path="/:handle/note/:rkey"
285 element={<AnnotationDetailRoute />}
286 />
287 <Route
288 path="/:handle/annotation/:rkey"
289 element={<AnnotationDetailRoute />}
290 />
291 <Route
292 path="/:handle/highlight/:rkey"
293 element={<AnnotationDetailRoute />}
294 />
295 <Route
296 path="/:handle/bookmark/:rkey"
297 element={<AnnotationDetailRoute />}
298 />
299 <Route
300 path="/annotation/:uri"
301 element={<UriAnnotationRoute />}
302 />
303 <Route path="/at/:did/:rkey" element={<AtAnnotationRoute />} />
304 <Route
305 path="/at/:did/:collection/:rkey"
306 element={<AtCollectionAnnotationRoute />}
307 />
308 <Route path="/url/*" element={<UrlRoute />} />
309 <Route path="/:handle/url/*" element={<UserUrlRoute />} />
310 <Route path="/profile/:did" element={<ProfileRoute />} />
311 <Route
312 path="/profile"
313 element={
314 <AuthGuard>
315 <ProfileSelfRedirect />
316 </AuthGuard>
317 }
318 />
319 <Route path="*" element={<Navigate to="/home" replace />} />
320 </Routes>
321 </div>
322 </main>
323
324 <RightSidebar onNavigate={navigate} />
325 </div>
326 </div>
327
328 <MobileNav currentPath={location.pathname} onNavigate={navigate} />
329 </div>
330 );
331}
332
333function ProfileSelfRedirect() {
334 const user = useStore($user);
335 if (!user) return null;
336 return <Navigate to={`/profile/${user.did}`} replace />;
337}
338
339export default function AppShell() {
340 useState(() => {
341 const ssrUser = window.__MARGIN_USER__;
342 if (ssrUser !== undefined) {
343 $user.set(ssrUser);
344 }
345 });
346
347 useEffect(() => {
348 const ssrUser = window.__MARGIN_USER__;
349 if ($user.get() === null && ssrUser === null) return;
350
351 if (ssrUser) {
352 checkSession().then((user) => {
353 if (user) $user.set(user);
354 });
355 } else if (ssrUser === undefined) {
356 checkSession().then((user) => {
357 $user.set(user);
358 });
359 }
360 }, []);
361
362 return (
363 <I18nextProvider i18n={i18n}>
364 <Suspense fallback={null}>
365 <BrowserRouter>
366 <AppLayout />
367 </BrowserRouter>
368 </Suspense>
369 </I18nextProvider>
370 );
371}