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: switch to modal login dialog

+256 -127
+63
web/src/components/LoginModal.tsx
··· 1 + import { useEffect } from "react"; 2 + import { X } from "lucide-react"; 3 + import { useLoginModal } from "../lib/loginModal"; 4 + import LoginForm from "./form/LoginForm"; 5 + 6 + export default function LoginModal() { 7 + const { open, closeLogin } = useLoginModal(); 8 + 9 + useEffect(() => { 10 + if (!open) return; 11 + function onKey(e: KeyboardEvent) { 12 + if (e.key === "Escape") closeLogin(); 13 + } 14 + document.addEventListener("keydown", onKey); 15 + const prevOverflow = document.body.style.overflow; 16 + document.body.style.overflow = "hidden"; 17 + return () => { 18 + document.removeEventListener("keydown", onKey); 19 + document.body.style.overflow = prevOverflow; 20 + }; 21 + }, [open, closeLogin]); 22 + 23 + if (!open) return null; 24 + 25 + return ( 26 + <div 27 + role="dialog" 28 + aria-modal="true" 29 + 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 + onClick={closeLogin} 32 + > 33 + <div 34 + className="relative w-full max-w-md bg-neutral-950 border border-neutral-800 rounded-lg p-6 shadow-xl" 35 + onClick={(e) => e.stopPropagation()} 36 + > 37 + <button 38 + type="button" 39 + onClick={closeLogin} 40 + aria-label="Close" 41 + className="absolute top-3 right-3 text-neutral-500 hover:text-neutral-300" 42 + > 43 + <X size={18} /> 44 + </button> 45 + <h2 className="text-xl text-neutral-200 mb-1">Log in</h2> 46 + <p className="text-sm text-neutral-400 mb-5"> 47 + Use any{" "} 48 + <a 49 + href="https://atproto.com" 50 + className="hover:text-neutral-300 underline underline-offset-2" 51 + > 52 + AT Protocol 53 + </a>{" "} 54 + account. 55 + </p> 56 + <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> 60 + </div> 61 + </div> 62 + ); 63 + }
+119
web/src/components/form/LoginForm.tsx
··· 1 + import { useState, type SyntheticEvent } from "react"; 2 + import { LogIn } from "lucide-react"; 3 + import { useAuth } from "../../lib/auth"; 4 + import { useHandleSearch } from "../../hooks/useHandleSearch"; 5 + import { useDropdown } from "../../hooks/useDropdown"; 6 + import HandleInput from "./HandleInput"; 7 + import { Button } from "./Form"; 8 + 9 + interface LoginFormProps { 10 + autoFocus?: boolean; 11 + idPrefix?: string; 12 + } 13 + 14 + export default function LoginForm({ 15 + autoFocus, 16 + idPrefix = "login", 17 + }: LoginFormProps) { 18 + const { login } = useAuth(); 19 + const [handle, setHandle] = useState(""); 20 + const [error, setError] = useState<string | null>(null); 21 + const [busy, setBusy] = useState(false); 22 + const matches = useHandleSearch(handle); 23 + const dropdown = useDropdown(matches.length, (index) => 24 + selectHandle(matches[index].handle), 25 + ); 26 + 27 + async function onSubmit(event: SyntheticEvent) { 28 + event.preventDefault(); 29 + setError(null); 30 + setBusy(true); 31 + try { 32 + await login(handle.trim()); 33 + } catch (err: unknown) { 34 + setError(err instanceof Error ? err.message : "Could not log in."); 35 + setBusy(false); 36 + } 37 + } 38 + 39 + function selectHandle(selected: string) { 40 + setHandle(selected); 41 + dropdown.close(); 42 + } 43 + 44 + const dropdownOpen = dropdown.focused && matches.length > 0; 45 + 46 + return ( 47 + <> 48 + {error && <p className="text-red-400 mb-4 text-center">{error}</p>} 49 + 50 + <div 51 + onFocus={dropdown.onFocus} 52 + onBlur={dropdown.onBlur} 53 + onKeyDown={dropdown.onKeyDown} 54 + > 55 + <form onSubmit={onSubmit} className="flex gap-2"> 56 + <HandleInput 57 + name="handle" 58 + value={handle} 59 + onChange={setHandle} 60 + required 61 + autoFocus={autoFocus} 62 + className="flex-1" 63 + aria-autocomplete="list" 64 + aria-expanded={dropdownOpen} 65 + aria-activedescendant={ 66 + dropdown.activeIndex >= 0 67 + ? `${idPrefix}-option-${dropdown.activeIndex}` 68 + : undefined 69 + } 70 + aria-label="Enter your handle" 71 + /> 72 + <Button type="submit" disabled={busy}> 73 + {busy ? "..." : <LogIn size={16} />} 74 + </Button> 75 + </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> 115 + )} 116 + </div> 117 + </> 118 + ); 119 + }
+4 -2
web/src/components/layout/Header.tsx
··· 1 1 import { Link, useNavigate } from "react-router-dom"; 2 2 import { useAuth } from "../../lib/auth"; 3 + import { useLoginModal } from "../../lib/loginModal"; 3 4 import Logo from "./Logo"; 4 5 import HeaderBreadcrumbs from "./HeaderBreadcrumbs"; 5 6 import MobileMenu from "./MobileMenu"; ··· 8 9 9 10 export default function Header() { 10 11 const { user, logout } = useAuth(); 12 + const { openLogin } = useLoginModal(); 11 13 const navigate = useNavigate(); 12 14 13 15 async function onLogout() { ··· 39 41 </button> 40 42 </> 41 43 ) : ( 42 - <Link to="/login" className={linkStyle}> 44 + <button type="button" onClick={openLogin} className={linkStyle}> 43 45 log in 44 - </Link> 46 + </button> 45 47 )} 46 48 </div> 47 49 <MobileMenu user={user} onLogout={onLogout} />
+19 -14
web/src/components/layout/Layout.tsx
··· 2 2 import Header from "./Header"; 3 3 import MobileBackButton from "./MobileBackButton"; 4 4 import Footer from "./Footer"; 5 + import LoginModal from "../LoginModal"; 6 + import { LoginModalProvider } from "../../lib/loginModal"; 5 7 6 8 export default function Layout() { 7 9 const isLoading = useNavigation().state === "loading"; 8 10 9 11 return ( 10 - <div className="flex flex-col h-dvh"> 11 - {isLoading && ( 12 - <div 13 - className="fixed top-0 left-0 right-0 h-0.5 bg-neutral-400 z-50" 14 - style={{ animation: "atbbs-progress 1.5s ease-out infinite" }} 15 - /> 16 - )} 17 - <Header /> 18 - <main className="max-w-2xl mx-auto px-4 py-8 flex-1 w-full"> 19 - <MobileBackButton /> 20 - <Outlet /> 21 - </main> 22 - <Footer /> 23 - </div> 12 + <LoginModalProvider> 13 + <div className="flex flex-col h-dvh"> 14 + {isLoading && ( 15 + <div 16 + className="fixed top-0 left-0 right-0 h-0.5 bg-neutral-400 z-50" 17 + style={{ animation: "atbbs-progress 1.5s ease-out infinite" }} 18 + /> 19 + )} 20 + <Header /> 21 + <main className="max-w-2xl mx-auto px-4 py-8 flex-1 w-full"> 22 + <MobileBackButton /> 23 + <Outlet /> 24 + </main> 25 + <Footer /> 26 + <LoginModal /> 27 + </div> 28 + </LoginModalProvider> 24 29 ); 25 30 }
+9 -4
web/src/components/layout/MobileMenu.tsx
··· 1 1 import { Link } from "react-router-dom"; 2 2 import { useState } from "react"; 3 3 import type { useAuth } from "../../lib/auth"; 4 + import { useLoginModal } from "../../lib/loginModal"; 4 5 5 6 interface MobileMenuProps { 6 7 user: ReturnType<typeof useAuth>["user"]; ··· 18 19 19 20 export default function MobileMenu({ user, onLogout }: MobileMenuProps) { 20 21 const [open, setOpen] = useState(false); 22 + const { openLogin } = useLoginModal(); 21 23 22 24 function close() { 23 25 setOpen(false); ··· 56 58 </button> 57 59 </> 58 60 ) : ( 59 - <Link 60 - to="/login" 61 - onClick={close} 61 + <button 62 + type="button" 63 + onClick={() => { 64 + close(); 65 + openLogin(); 66 + }} 62 67 className="text-neutral-300 hover:text-neutral-200" 63 68 > 64 69 log in 65 - </Link> 70 + </button> 66 71 )} 67 72 </div> 68 73 )}
+33
web/src/lib/loginModal.tsx
··· 1 + import { 2 + createContext, 3 + useCallback, 4 + useContext, 5 + useState, 6 + type ReactNode, 7 + } from "react"; 8 + 9 + interface LoginModalCtx { 10 + open: boolean; 11 + openLogin: () => void; 12 + closeLogin: () => void; 13 + } 14 + 15 + const LoginModalContext = createContext<LoginModalCtx | null>(null); 16 + 17 + export function LoginModalProvider({ children }: { children: ReactNode }) { 18 + const [open, setOpen] = useState(false); 19 + const openLogin = useCallback(() => setOpen(true), []); 20 + const closeLogin = useCallback(() => setOpen(false), []); 21 + return ( 22 + <LoginModalContext.Provider value={{ open, openLogin, closeLogin }}> 23 + {children} 24 + </LoginModalContext.Provider> 25 + ); 26 + } 27 + 28 + export function useLoginModal(): LoginModalCtx { 29 + const ctx = useContext(LoginModalContext); 30 + if (!ctx) 31 + throw new Error("useLoginModal must be used within LoginModalProvider"); 32 + return ctx; 33 + }
+8 -106
web/src/pages/Login.tsx
··· 1 - import { useState, type SyntheticEvent } from "react"; 2 - import { LogIn, MessageSquare, Pin, User, Monitor } from "lucide-react"; 3 - import { useAuth } from "../lib/auth"; 1 + import { MessageSquare, Pin, User, Monitor } from "lucide-react"; 4 2 import { usePageTitle } from "../hooks/usePageTitle"; 5 - import { useHandleSearch } from "../hooks/useHandleSearch"; 6 - import { useDropdown } from "../hooks/useDropdown"; 7 - import HandleInput from "../components/form/HandleInput"; 8 - import { Button } from "../components/form/Form"; 3 + import LoginForm from "../components/form/LoginForm"; 9 4 10 5 export default function Login() { 11 - const { login } = useAuth(); 12 - const [handle, setHandle] = useState(""); 13 - const [error, setError] = useState<string | null>(null); 14 - const [busy, setBusy] = useState(false); 15 - const matches = useHandleSearch(handle); 16 - const dropdown = useDropdown(matches.length, (index) => 17 - selectHandle(matches[index].handle), 18 - ); 19 6 usePageTitle("Login — atbbs"); 20 7 21 - async function onSubmit(event: SyntheticEvent) { 22 - event.preventDefault(); 23 - setError(null); 24 - setBusy(true); 25 - try { 26 - await login(handle.trim()); 27 - } catch (err: unknown) { 28 - setError(err instanceof Error ? err.message : "Could not log in."); 29 - setBusy(false); 30 - } 31 - } 32 - 33 - function selectHandle(selected: string) { 34 - setHandle(selected); 35 - dropdown.close(); 36 - } 37 - 38 - const dropdownOpen = dropdown.focused && matches.length > 0; 39 - 40 8 return ( 41 9 <div className="h-full flex flex-col justify-center overflow-hidden"> 42 10 <div className="text-center mb-8"> ··· 49 17 src="/hero.svg" 50 18 alt="@bbs" 51 19 className="mx-auto mb-4" 52 - style={{ width: 276, imageRendering: "pixelated" }} 20 + style={{ width: 140, imageRendering: "pixelated" }} 53 21 /> 54 22 </picture> 55 - <h1 className="text-lg text-neutral-400 mb-2"> 56 - Log in with any{" "} 23 + <p className="text-neutral-400"> 24 + Use any{" "} 57 25 <a 58 26 href="https://atproto.com" 59 27 className="text-neutral-400 hover:text-neutral-300 underline underline-offset-2" ··· 61 29 AT Protocol 62 30 </a>{" "} 63 31 account. 64 - </h1> 32 + </p> 65 33 </div> 66 34 67 - {error && <p className="text-red-400 mb-4 text-center">{error}</p>} 68 - 69 - <div 70 - onFocus={dropdown.onFocus} 71 - onBlur={dropdown.onBlur} 72 - onKeyDown={dropdown.onKeyDown} 73 - className="mb-6" 74 - > 75 - <form onSubmit={onSubmit} className="flex gap-2"> 76 - <HandleInput 77 - name="handle" 78 - value={handle} 79 - onChange={setHandle} 80 - required 81 - className="flex-1" 82 - aria-autocomplete="list" 83 - aria-expanded={dropdownOpen} 84 - aria-activedescendant={ 85 - dropdown.activeIndex >= 0 86 - ? `login-option-${dropdown.activeIndex}` 87 - : undefined 88 - } 89 - aria-label="Enter your handle" 90 - /> 91 - <Button type="submit" disabled={busy}> 92 - {busy ? "..." : <LogIn size={16} />} 93 - </Button> 94 - </form> 95 - {dropdownOpen && ( 96 - <div className="relative"> 97 - <div 98 - role="listbox" 99 - className="absolute left-0 right-0 mt-1 bg-neutral-900 border border-neutral-800 rounded shadow-lg z-10" 100 - > 101 - {matches.map((match, index) => ( 102 - <button 103 - key={match.handle} 104 - id={`login-option-${index}`} 105 - type="button" 106 - role="option" 107 - aria-selected={index === dropdown.activeIndex} 108 - onClick={() => selectHandle(match.handle)} 109 - className={`flex items-center gap-3 w-full px-3 py-2 text-left first:rounded-t last:rounded-b ${ 110 - index === dropdown.activeIndex 111 - ? "bg-neutral-800" 112 - : "hover:bg-neutral-800" 113 - }`} 114 - > 115 - {match.avatar && ( 116 - <img 117 - src={match.avatar} 118 - alt="" 119 - className="w-6 h-6 rounded-full shrink-0" 120 - /> 121 - )} 122 - <div className="min-w-0"> 123 - <div className="text-sm text-neutral-200 truncate"> 124 - {match.displayName} 125 - </div> 126 - <div className="text-xs text-neutral-400 truncate"> 127 - {match.handle} 128 - </div> 129 - </div> 130 - </button> 131 - ))} 132 - </div> 133 - </div> 134 - )} 35 + <div className="mb-6"> 36 + <LoginForm /> 135 37 </div> 136 38 137 39 <div className="bg-neutral-900 border border-neutral-800 rounded p-4 text-xs text-neutral-400 space-y-3">
+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/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/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/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/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"}