Retro Bulletin Board Systems on atproto. Web app and TUI. lazy mirror of alyraffauf/atbbs atbbs.xyz
forums python tui atproto bbs
3
fork

Configure Feed

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

web: overhaul login form

+188 -137
+36
web/src/components/AtprotoAppsCard.tsx
··· 1 + import { ExternalLink } from "lucide-react"; 2 + import type { AtprotoApp } from "../lib/atprotoApps"; 3 + 4 + interface AtprotoAppsCardProps { 5 + apps: AtprotoApp[]; 6 + } 7 + 8 + export default function AtprotoAppsCard({ apps }: AtprotoAppsCardProps) { 9 + return ( 10 + <div className="bg-neutral-900 border border-neutral-800 rounded p-4"> 11 + <p className="text-xs text-neutral-500 mb-2"> 12 + The same account works for apps like: 13 + </p> 14 + <ul className="space-y-2 text-sm"> 15 + {apps.map((app) => ( 16 + <li 17 + key={app.name} 18 + className="flex items-center gap-2 text-neutral-300" 19 + > 20 + <span className="text-neutral-600">•</span> 21 + <span>{app.name}</span> 22 + <a 23 + href={app.url} 24 + target="_blank" 25 + rel="noreferrer" 26 + aria-label={`Open ${app.name}`} 27 + className="text-neutral-500 hover:text-neutral-300" 28 + > 29 + <ExternalLink size={12} /> 30 + </a> 31 + </li> 32 + ))} 33 + </ul> 34 + </div> 35 + ); 36 + }
+1 -1
web/src/components/ErrorPage.tsx
··· 24 24 } else { 25 25 detail = 26 26 "This account isn't running a BBS yet. Is this you? Log in to start one."; 27 - action = { to: "/login", label: "log in" }; 27 + action = { to: "/?login=1", label: "log in" }; 28 28 } 29 29 } else if (error instanceof NetworkError) { 30 30 title = "Couldn't reach the network.";
+23 -14
web/src/components/LoginModal.tsx
··· 1 - import { useEffect } from "react"; 1 + import { useEffect, useState } from "react"; 2 2 import { X } from "lucide-react"; 3 3 import { useLoginModal } from "../lib/loginModal"; 4 + import { pickRandomApps } from "../lib/atprotoApps"; 4 5 import LoginForm from "./form/LoginForm"; 6 + import AtprotoAppsCard from "./AtprotoAppsCard"; 5 7 6 8 export default function LoginModal() { 7 9 const { open, closeLogin } = useLoginModal(); 10 + const [apps] = useState(() => pickRandomApps(3)); 8 11 9 12 useEffect(() => { 10 13 if (!open) return; 11 - function onKey(e: KeyboardEvent) { 12 - if (e.key === "Escape") closeLogin(); 14 + 15 + function onKeyDown(event: KeyboardEvent) { 16 + if (event.key === "Escape") closeLogin(); 13 17 } 14 - document.addEventListener("keydown", onKey); 15 - const prevOverflow = document.body.style.overflow; 18 + 19 + const previousOverflow = document.body.style.overflow; 16 20 document.body.style.overflow = "hidden"; 21 + document.addEventListener("keydown", onKeyDown); 22 + 17 23 return () => { 18 - document.removeEventListener("keydown", onKey); 19 - document.body.style.overflow = prevOverflow; 24 + document.body.style.overflow = previousOverflow; 25 + document.removeEventListener("keydown", onKeyDown); 20 26 }; 21 27 }, [open, closeLogin]); 22 28 ··· 27 33 role="dialog" 28 34 aria-modal="true" 29 35 aria-label="Log in" 30 - className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 px-4 pt-16 md:items-center md:pt-0" 31 36 onClick={closeLogin} 37 + className="fixed inset-0 z-50 flex items-start justify-center bg-black/80 px-4 pt-16 md:items-center md:pt-0" 32 38 > 33 39 <div 34 - className="relative w-full max-w-md bg-neutral-950 border border-neutral-800 rounded-lg p-6 shadow-xl" 35 40 onClick={(e) => e.stopPropagation()} 41 + className="relative w-full max-w-md bg-neutral-950 border border-neutral-800 rounded-lg p-6 shadow-xl" 36 42 > 37 43 <button 38 44 type="button" ··· 42 48 > 43 49 <X size={18} /> 44 50 </button> 45 - <h2 className="text-xl text-neutral-200 mb-1">Log in</h2> 46 - <p className="text-sm text-neutral-400 mb-5"> 51 + 52 + <h2 className="text-2xl text-neutral-200 mb-4">Log in</h2> 53 + <p className="text-sm text-neutral-400 mb-6 leading-relaxed"> 47 54 Use any{" "} 48 55 <a 49 56 href="https://atproto.com" ··· 53 60 </a>{" "} 54 61 account. 55 62 </p> 63 + 56 64 <LoginForm autoFocus idPrefix="login-modal" /> 57 - <p className="text-xs text-neutral-500 mt-4"> 58 - We'll redirect you to your provider to continue. 59 - </p> 65 + 66 + <div className="mt-6"> 67 + <AtprotoAppsCard apps={apps} /> 68 + </div> 60 69 </div> 61 70 </div> 62 71 );
+5 -1
web/src/components/form/HandleInput.tsx
··· 31 31 useEffect(() => { 32 32 const timer = setInterval(() => { 33 33 setPlaceholderIndex((i) => (i + 1) % PLACEHOLDERS.length); 34 - }, 3000); 34 + }, 6000); 35 35 36 36 return () => clearInterval(timer); 37 37 }, []); ··· 42 42 value={value} 43 43 onChange={(e) => onChange(e.target.value)} 44 44 placeholder={PLACEHOLDERS[placeholderIndex]} 45 + spellCheck={false} 46 + autoCapitalize="none" 47 + autoCorrect="off" 48 + autoComplete="off" 45 49 className={`${inputStyles} ${className}`} 46 50 {...rest} 47 51 />
+57
web/src/components/form/HandleSuggestions.tsx
··· 1 + import type { HandleMatch } from "../../lib/bsky"; 2 + 3 + interface HandleSuggestionsProps { 4 + suggestions: HandleMatch[]; 5 + activeIndex: number; 6 + onSelect: (handle: string) => void; 7 + idPrefix: string; 8 + } 9 + 10 + export default function HandleSuggestions({ 11 + suggestions, 12 + activeIndex, 13 + onSelect, 14 + idPrefix, 15 + }: HandleSuggestionsProps) { 16 + return ( 17 + <div className="relative"> 18 + <div 19 + role="listbox" 20 + className="absolute left-0 right-0 mt-1 bg-neutral-900 border border-neutral-800 rounded shadow-lg z-10" 21 + > 22 + {suggestions.map((suggestion, index) => { 23 + const isActive = index === activeIndex; 24 + return ( 25 + <button 26 + key={suggestion.handle} 27 + id={`${idPrefix}-option-${index}`} 28 + type="button" 29 + role="option" 30 + aria-selected={isActive} 31 + onClick={() => onSelect(suggestion.handle)} 32 + className={`flex items-center gap-3 w-full px-3 py-2 text-left first:rounded-t last:rounded-b ${ 33 + isActive ? "bg-neutral-800" : "hover:bg-neutral-800" 34 + }`} 35 + > 36 + {suggestion.avatar && ( 37 + <img 38 + src={suggestion.avatar} 39 + alt="" 40 + className="w-6 h-6 rounded-full shrink-0" 41 + /> 42 + )} 43 + <div className="min-w-0"> 44 + <div className="text-sm text-neutral-200 truncate"> 45 + {suggestion.displayName} 46 + </div> 47 + <div className="text-xs text-neutral-400 truncate"> 48 + {suggestion.handle} 49 + </div> 50 + </div> 51 + </button> 52 + ); 53 + })} 54 + </div> 55 + </div> 56 + ); 57 + }
+23 -53
web/src/components/form/LoginForm.tsx
··· 4 4 import { useHandleSearch } from "../../hooks/useHandleSearch"; 5 5 import { useDropdown } from "../../hooks/useDropdown"; 6 6 import HandleInput from "./HandleInput"; 7 + import HandleSuggestions from "./HandleSuggestions"; 7 8 import { Button } from "./Form"; 8 9 9 10 interface LoginFormProps { ··· 19 20 const [handle, setHandle] = useState(""); 20 21 const [error, setError] = useState<string | null>(null); 21 22 const [busy, setBusy] = useState(false); 22 - const matches = useHandleSearch(handle); 23 - const dropdown = useDropdown(matches.length, (index) => 24 - selectHandle(matches[index].handle), 23 + 24 + const suggestions = useHandleSearch(handle); 25 + const dropdown = useDropdown(suggestions.length, (index) => 26 + selectHandle(suggestions[index].handle), 25 27 ); 28 + const showSuggestions = dropdown.focused && suggestions.length > 0; 29 + 30 + function selectHandle(selected: string) { 31 + setHandle(selected); 32 + dropdown.close(); 33 + } 26 34 27 35 async function onSubmit(event: SyntheticEvent) { 28 36 event.preventDefault(); ··· 30 38 setBusy(true); 31 39 try { 32 40 await login(handle.trim()); 33 - } catch (err: unknown) { 34 - setError(err instanceof Error ? err.message : "Could not log in."); 41 + } catch (err) { 42 + console.error("Login failed:", err); 43 + setError("Couldn't find that handle. Double-check the spelling?"); 35 44 setBusy(false); 36 45 } 37 46 } 38 47 39 - function selectHandle(selected: string) { 40 - setHandle(selected); 41 - dropdown.close(); 42 - } 43 - 44 - const dropdownOpen = dropdown.focused && matches.length > 0; 45 - 46 48 return ( 47 49 <> 48 - {error && <p className="text-red-400 mb-4 text-center">{error}</p>} 50 + {error && <p className="text-sm text-red-400 mb-3">{error}</p>} 49 51 50 52 <div 51 53 onFocus={dropdown.onFocus} ··· 61 63 autoFocus={autoFocus} 62 64 className="flex-1" 63 65 aria-autocomplete="list" 64 - aria-expanded={dropdownOpen} 66 + aria-expanded={showSuggestions} 65 67 aria-activedescendant={ 66 68 dropdown.activeIndex >= 0 67 69 ? `${idPrefix}-option-${dropdown.activeIndex}` ··· 73 75 {busy ? "..." : <LogIn size={16} />} 74 76 </Button> 75 77 </form> 76 - {dropdownOpen && ( 77 - <div className="relative"> 78 - <div 79 - role="listbox" 80 - className="absolute left-0 right-0 mt-1 bg-neutral-900 border border-neutral-800 rounded shadow-lg z-10" 81 - > 82 - {matches.map((match, index) => ( 83 - <button 84 - key={match.handle} 85 - id={`${idPrefix}-option-${index}`} 86 - type="button" 87 - role="option" 88 - aria-selected={index === dropdown.activeIndex} 89 - onClick={() => selectHandle(match.handle)} 90 - className={`flex items-center gap-3 w-full px-3 py-2 text-left first:rounded-t last:rounded-b ${ 91 - index === dropdown.activeIndex 92 - ? "bg-neutral-800" 93 - : "hover:bg-neutral-800" 94 - }`} 95 - > 96 - {match.avatar && ( 97 - <img 98 - src={match.avatar} 99 - alt="" 100 - className="w-6 h-6 rounded-full shrink-0" 101 - /> 102 - )} 103 - <div className="min-w-0"> 104 - <div className="text-sm text-neutral-200 truncate"> 105 - {match.displayName} 106 - </div> 107 - <div className="text-xs text-neutral-400 truncate"> 108 - {match.handle} 109 - </div> 110 - </div> 111 - </button> 112 - ))} 113 - </div> 114 - </div> 78 + {showSuggestions && ( 79 + <HandleSuggestions 80 + suggestions={suggestions} 81 + activeIndex={dropdown.activeIndex} 82 + onSelect={selectHandle} 83 + idPrefix={idPrefix} 84 + /> 115 85 )} 116 86 </div> 117 87 </>
+20
web/src/lib/atprotoApps.ts
··· 1 + export interface AtprotoApp { 2 + name: string; 3 + url: string; 4 + } 5 + 6 + export const ATPROTO_APPS: AtprotoApp[] = [ 7 + { name: "Blacksky", url: "https://blacksky.community" }, 8 + { name: "Bluesky", url: "https://bsky.app" }, 9 + { name: "Grain Social", url: "https://grain.social" }, 10 + { name: "Leaflet", url: "https://leaflet.pub" }, 11 + { name: "pckt.blog", url: "https://pckt.blog" }, 12 + { name: "Streamplace", url: "https://stream.place" }, 13 + { name: "Tangled", url: "https://tangled.sh" }, 14 + { name: "wisp.place", url: "https://wisp.place" }, 15 + ]; 16 + 17 + export function pickRandomApps(count: number): AtprotoApp[] { 18 + const shuffled = [...ATPROTO_APPS].sort(() => Math.random() - 0.5); 19 + return shuffled.slice(0, count); 20 + }
+3 -3
web/src/lib/auth.ts
··· 168 168 // --- Login --- 169 169 170 170 async function login(handle: string): Promise<void> { 171 - // Remember where to send the user after the OAuth round-trip, but 172 - // never back to /login or /oauth/callback (that would loop). 171 + // Remember where to send the user after the OAuth round-trip, but never 172 + // back to /oauth/callback (that would loop). 173 173 try { 174 174 const here = window.location.pathname; 175 - const dest = here === "/login" || here.startsWith("/oauth/") ? "/" : here; 175 + const dest = here.startsWith("/oauth/") ? "/" : here; 176 176 sessionStorage.setItem(POST_LOGIN_KEY, dest); 177 177 } catch { 178 178 // non-fatal
+18
web/src/lib/loginModal.tsx
··· 2 2 createContext, 3 3 useCallback, 4 4 useContext, 5 + useEffect, 5 6 useState, 6 7 type ReactNode, 7 8 } from "react"; 9 + import { useLocation, useNavigate } from "react-router-dom"; 8 10 9 11 interface LoginModalCtx { 10 12 open: boolean; ··· 18 20 const [open, setOpen] = useState(false); 19 21 const openLogin = useCallback(() => setOpen(true), []); 20 22 const closeLogin = useCallback(() => setOpen(false), []); 23 + 24 + // Open the modal when we land on a URL with ?login=1 (auth-required loader 25 + // redirects use this), then strip the param so refreshes don't re-trigger. 26 + const location = useLocation(); 27 + const navigate = useNavigate(); 28 + useEffect(() => { 29 + const params = new URLSearchParams(location.search); 30 + if (params.get("login") !== "1") return; 31 + setOpen(true); 32 + params.delete("login"); 33 + const remaining = params.toString(); 34 + navigate(location.pathname + (remaining ? `?${remaining}` : ""), { 35 + replace: true, 36 + }); 37 + }, [location.pathname, location.search, navigate]); 38 + 21 39 return ( 22 40 <LoginModalContext.Provider value={{ open, openLogin, closeLogin }}> 23 41 {children}
-61
web/src/pages/Login.tsx
··· 1 - import { MessageSquare, Pin, User, Monitor } from "lucide-react"; 2 - import { usePageTitle } from "../hooks/usePageTitle"; 3 - import LoginForm from "../components/form/LoginForm"; 4 - 5 - export default function Login() { 6 - usePageTitle("Login — atbbs"); 7 - 8 - return ( 9 - <div className="h-full flex flex-col justify-center overflow-hidden"> 10 - <div className="text-center mb-8"> 11 - <picture> 12 - <source 13 - srcSet="/hero-dark.svg" 14 - media="(prefers-color-scheme: dark)" 15 - /> 16 - <img 17 - src="/hero.svg" 18 - alt="@bbs" 19 - className="mx-auto mb-4" 20 - style={{ width: 140, imageRendering: "pixelated" }} 21 - /> 22 - </picture> 23 - <p className="text-neutral-400"> 24 - Use any{" "} 25 - <a 26 - href="https://atproto.com" 27 - className="text-neutral-400 hover:text-neutral-300 underline underline-offset-2" 28 - > 29 - AT Protocol 30 - </a>{" "} 31 - account. 32 - </p> 33 - </div> 34 - 35 - <div className="mb-6"> 36 - <LoginForm /> 37 - </div> 38 - 39 - <div className="bg-neutral-900 border border-neutral-800 rounded p-4 text-xs text-neutral-400 space-y-3"> 40 - <p>Once signed in, you can:</p> 41 - <ul className="space-y-2"> 42 - <li className="flex items-center gap-2"> 43 - <MessageSquare size={14} /> Post threads and replies 44 - </li> 45 - <li className="flex items-center gap-2"> 46 - <Pin size={14} /> Pin boards you like 47 - </li> 48 - <li className="flex items-center gap-2"> 49 - <User size={14} /> Set up a profile 50 - </li> 51 - <li className="flex items-center gap-2"> 52 - <Monitor size={14} /> Start your own community 53 - </li> 54 - </ul> 55 - <p className="text-neutral-400 pt-3 border-t border-neutral-800"> 56 - We'll redirect you to your provider to continue. 57 - </p> 58 - </div> 59 - </div> 60 - ); 61 - }
+1 -1
web/src/router/loaders/auth.ts
··· 4 4 export async function requireAuth() { 5 5 await ensureAuthReady(); 6 6 const user = getCurrentUser(); 7 - if (!user) throw redirect("/login"); 7 + if (!user) throw redirect("/?login=1"); 8 8 return user; 9 9 }
-2
web/src/router/routes.tsx
··· 9 9 import ErrorPage from "../components/ErrorPage"; 10 10 11 11 import Home from "../pages/Home"; 12 - import Login from "../pages/Login"; 13 12 import OAuthCallback from "../pages/OAuthCallback"; 14 13 import Profile from "../pages/Profile"; 15 14 import BBS from "../pages/BBS"; ··· 38 37 errorElement: <ErrorPage />, 39 38 children: [ 40 39 { path: "/", loader: homeLoader, element: <Home /> }, 41 - { path: "/login", element: <Login /> }, 42 40 { path: "/oauth/callback", element: <OAuthCallback /> }, 43 41 { path: "/account", loader: () => redirect("/") }, 44 42 {
+1 -1
web/tsconfig.tsbuildinfo
··· 1 - {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/ActivityList.tsx","./src/components/BBSPanel.tsx","./src/components/DialBBS.tsx","./src/components/DiscoveryList.tsx","./src/components/ErrorPage.tsx","./src/components/Localtime.tsx","./src/components/LoginModal.tsx","./src/components/MyThreadList.tsx","./src/components/PinButton.tsx","./src/components/PinnedList.tsx","./src/components/form/BoardRowEditor.tsx","./src/components/form/ComposeForm.tsx","./src/components/form/FileChips.tsx","./src/components/form/Form.tsx","./src/components/form/HandleInput.tsx","./src/components/form/LoginForm.tsx","./src/components/layout/Footer.tsx","./src/components/layout/Header.tsx","./src/components/layout/HeaderBreadcrumbs.tsx","./src/components/layout/Layout.tsx","./src/components/layout/Logo.tsx","./src/components/layout/MobileBackButton.tsx","./src/components/layout/MobileMenu.tsx","./src/components/nav/ActionBar.tsx","./src/components/nav/ActionButton.tsx","./src/components/nav/ListLink.tsx","./src/components/nav/PageNav.tsx","./src/components/nav/ThreadLink.tsx","./src/components/post/AttachmentLink.tsx","./src/components/post/NewsCard.tsx","./src/components/post/PostActions.tsx","./src/components/post/PostBody.tsx","./src/components/post/PostMeta.tsx","./src/components/post/ReplyCard.tsx","./src/components/post/ThreadCard.tsx","./src/components/profile/EditProfile.tsx","./src/components/profile/ViewProfile.tsx","./src/hooks/useBreadcrumb.tsx","./src/hooks/useDiscovery.ts","./src/hooks/useDropdown.ts","./src/hooks/useHandleSearch.ts","./src/hooks/usePageTitle.ts","./src/hooks/useResolvedBBS.ts","./src/hooks/useThreadReplies.ts","./src/lexicons/index.ts","./src/lexicons/types/xyz/atbbs/ban.ts","./src/lexicons/types/xyz/atbbs/board.ts","./src/lexicons/types/xyz/atbbs/hide.ts","./src/lexicons/types/xyz/atbbs/pin.ts","./src/lexicons/types/xyz/atbbs/post.ts","./src/lexicons/types/xyz/atbbs/profile.ts","./src/lexicons/types/xyz/atbbs/site.ts","./src/lib/activity.ts","./src/lib/atproto.ts","./src/lib/auth.ts","./src/lib/bbs.ts","./src/lib/bsky.ts","./src/lib/cache.ts","./src/lib/deletebbs.ts","./src/lib/lexicon.ts","./src/lib/limits.ts","./src/lib/loginModal.tsx","./src/lib/mythreads.ts","./src/lib/pins.ts","./src/lib/profile.ts","./src/lib/replies.ts","./src/lib/util.ts","./src/lib/writes.ts","./src/pages/BBS.tsx","./src/pages/Board.tsx","./src/pages/Dashboard.tsx","./src/pages/Home.tsx","./src/pages/LoggedOutHome.tsx","./src/pages/Login.tsx","./src/pages/News.tsx","./src/pages/NotFound.tsx","./src/pages/OAuthCallback.tsx","./src/pages/Profile.tsx","./src/pages/SysopCreate.tsx","./src/pages/SysopEdit.tsx","./src/pages/SysopModerate.tsx","./src/pages/Thread.tsx","./src/router/routes.tsx","./src/router/loaders/account.ts","./src/router/loaders/auth.ts","./src/router/loaders/bbs.ts","./src/router/loaders/board.ts","./src/router/loaders/home.ts","./src/router/loaders/index.ts","./src/router/loaders/profile.ts","./src/router/loaders/sysop.ts","./src/router/loaders/thread.ts"],"version":"6.0.2"} 1 + {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/ActivityList.tsx","./src/components/AtprotoAppsCard.tsx","./src/components/BBSPanel.tsx","./src/components/DialBBS.tsx","./src/components/DiscoveryList.tsx","./src/components/ErrorPage.tsx","./src/components/Localtime.tsx","./src/components/LoginModal.tsx","./src/components/MyThreadList.tsx","./src/components/PinButton.tsx","./src/components/PinnedList.tsx","./src/components/form/BoardRowEditor.tsx","./src/components/form/ComposeForm.tsx","./src/components/form/FileChips.tsx","./src/components/form/Form.tsx","./src/components/form/HandleInput.tsx","./src/components/form/HandleSuggestions.tsx","./src/components/form/LoginForm.tsx","./src/components/layout/Footer.tsx","./src/components/layout/Header.tsx","./src/components/layout/HeaderBreadcrumbs.tsx","./src/components/layout/Layout.tsx","./src/components/layout/Logo.tsx","./src/components/layout/MobileBackButton.tsx","./src/components/layout/MobileMenu.tsx","./src/components/nav/ActionBar.tsx","./src/components/nav/ActionButton.tsx","./src/components/nav/ListLink.tsx","./src/components/nav/PageNav.tsx","./src/components/nav/ThreadLink.tsx","./src/components/post/AttachmentLink.tsx","./src/components/post/NewsCard.tsx","./src/components/post/PostActions.tsx","./src/components/post/PostBody.tsx","./src/components/post/PostMeta.tsx","./src/components/post/ReplyCard.tsx","./src/components/post/ThreadCard.tsx","./src/components/profile/EditProfile.tsx","./src/components/profile/ViewProfile.tsx","./src/hooks/useBreadcrumb.tsx","./src/hooks/useDiscovery.ts","./src/hooks/useDropdown.ts","./src/hooks/useHandleSearch.ts","./src/hooks/usePageTitle.ts","./src/hooks/useResolvedBBS.ts","./src/hooks/useThreadReplies.ts","./src/lexicons/index.ts","./src/lexicons/types/xyz/atbbs/ban.ts","./src/lexicons/types/xyz/atbbs/board.ts","./src/lexicons/types/xyz/atbbs/hide.ts","./src/lexicons/types/xyz/atbbs/pin.ts","./src/lexicons/types/xyz/atbbs/post.ts","./src/lexicons/types/xyz/atbbs/profile.ts","./src/lexicons/types/xyz/atbbs/site.ts","./src/lib/activity.ts","./src/lib/atproto.ts","./src/lib/atprotoApps.ts","./src/lib/auth.ts","./src/lib/bbs.ts","./src/lib/bsky.ts","./src/lib/cache.ts","./src/lib/deletebbs.ts","./src/lib/lexicon.ts","./src/lib/limits.ts","./src/lib/loginModal.tsx","./src/lib/mythreads.ts","./src/lib/pins.ts","./src/lib/profile.ts","./src/lib/replies.ts","./src/lib/util.ts","./src/lib/writes.ts","./src/pages/BBS.tsx","./src/pages/Board.tsx","./src/pages/Dashboard.tsx","./src/pages/Home.tsx","./src/pages/LoggedOutHome.tsx","./src/pages/News.tsx","./src/pages/NotFound.tsx","./src/pages/OAuthCallback.tsx","./src/pages/Profile.tsx","./src/pages/SysopCreate.tsx","./src/pages/SysopEdit.tsx","./src/pages/SysopModerate.tsx","./src/pages/Thread.tsx","./src/router/routes.tsx","./src/router/loaders/account.ts","./src/router/loaders/auth.ts","./src/router/loaders/bbs.ts","./src/router/loaders/board.ts","./src/router/loaders/home.ts","./src/router/loaders/index.ts","./src/router/loaders/profile.ts","./src/router/loaders/sysop.ts","./src/router/loaders/thread.ts"],"version":"6.0.2"}