(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
99
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 371 lines 12 kB view raw
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}