diff --git a/web/src/components/AtprotoAppsCard.tsx b/web/src/components/AtprotoAppsCard.tsx new file mode 100644 index 0000000..840c3cd --- /dev/null +++ b/web/src/components/AtprotoAppsCard.tsx @@ -0,0 +1,36 @@ +import { ExternalLink } from "lucide-react"; +import type { AtprotoApp } from "../lib/atprotoApps"; + +interface AtprotoAppsCardProps { + apps: AtprotoApp[]; +} + +export default function AtprotoAppsCard({ apps }: AtprotoAppsCardProps) { + return ( +
+

+ The same account works for apps like: +

+ +
+ ); +} diff --git a/web/src/components/ErrorPage.tsx b/web/src/components/ErrorPage.tsx index 16d4763..12a6cab 100644 --- a/web/src/components/ErrorPage.tsx +++ b/web/src/components/ErrorPage.tsx @@ -24,7 +24,7 @@ export default function ErrorPage() { } else { detail = "This account isn't running a BBS yet. Is this you? Log in to start one."; - action = { to: "/login", label: "log in" }; + action = { to: "/?login=1", label: "log in" }; } } else if (error instanceof NetworkError) { title = "Couldn't reach the network."; diff --git a/web/src/components/LoginModal.tsx b/web/src/components/LoginModal.tsx index d81b7a2..8b97c2f 100644 --- a/web/src/components/LoginModal.tsx +++ b/web/src/components/LoginModal.tsx @@ -1,22 +1,28 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { X } from "lucide-react"; import { useLoginModal } from "../lib/loginModal"; +import { pickRandomApps } from "../lib/atprotoApps"; import LoginForm from "./form/LoginForm"; +import AtprotoAppsCard from "./AtprotoAppsCard"; export default function LoginModal() { const { open, closeLogin } = useLoginModal(); + const [apps] = useState(() => pickRandomApps(3)); useEffect(() => { if (!open) return; - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") closeLogin(); + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") closeLogin(); } - document.addEventListener("keydown", onKey); - const prevOverflow = document.body.style.overflow; + + const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; + document.addEventListener("keydown", onKeyDown); + return () => { - document.removeEventListener("keydown", onKey); - document.body.style.overflow = prevOverflow; + document.body.style.overflow = previousOverflow; + document.removeEventListener("keydown", onKeyDown); }; }, [open, closeLogin]); @@ -27,12 +33,12 @@ export default function LoginModal() { role="dialog" aria-modal="true" aria-label="Log in" - className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 px-4 pt-16 md:items-center md:pt-0" onClick={closeLogin} + className="fixed inset-0 z-50 flex items-start justify-center bg-black/80 px-4 pt-16 md:items-center md:pt-0" >
e.stopPropagation()} + className="relative w-full max-w-md bg-neutral-950 border border-neutral-800 rounded-lg p-6 shadow-xl" > -

Log in

-

+ +

Log in

+

Use any{" "} {" "} account.

+ -

- We'll redirect you to your provider to continue. -

+ +
+ +
); diff --git a/web/src/components/form/HandleInput.tsx b/web/src/components/form/HandleInput.tsx index b4c1455..284eff9 100644 --- a/web/src/components/form/HandleInput.tsx +++ b/web/src/components/form/HandleInput.tsx @@ -31,7 +31,7 @@ export default function HandleInput({ useEffect(() => { const timer = setInterval(() => { setPlaceholderIndex((i) => (i + 1) % PLACEHOLDERS.length); - }, 3000); + }, 6000); return () => clearInterval(timer); }, []); @@ -42,6 +42,10 @@ export default function HandleInput({ value={value} onChange={(e) => onChange(e.target.value)} placeholder={PLACEHOLDERS[placeholderIndex]} + spellCheck={false} + autoCapitalize="none" + autoCorrect="off" + autoComplete="off" className={`${inputStyles} ${className}`} {...rest} /> diff --git a/web/src/components/form/HandleSuggestions.tsx b/web/src/components/form/HandleSuggestions.tsx new file mode 100644 index 0000000..b238fa7 --- /dev/null +++ b/web/src/components/form/HandleSuggestions.tsx @@ -0,0 +1,57 @@ +import type { HandleMatch } from "../../lib/bsky"; + +interface HandleSuggestionsProps { + suggestions: HandleMatch[]; + activeIndex: number; + onSelect: (handle: string) => void; + idPrefix: string; +} + +export default function HandleSuggestions({ + suggestions, + activeIndex, + onSelect, + idPrefix, +}: HandleSuggestionsProps) { + return ( +
+
+ {suggestions.map((suggestion, index) => { + const isActive = index === activeIndex; + return ( + + ); + })} +
+
+ ); +} diff --git a/web/src/components/form/LoginForm.tsx b/web/src/components/form/LoginForm.tsx index aedfb1c..e50b2bd 100644 --- a/web/src/components/form/LoginForm.tsx +++ b/web/src/components/form/LoginForm.tsx @@ -4,6 +4,7 @@ import { useAuth } from "../../lib/auth"; import { useHandleSearch } from "../../hooks/useHandleSearch"; import { useDropdown } from "../../hooks/useDropdown"; import HandleInput from "./HandleInput"; +import HandleSuggestions from "./HandleSuggestions"; import { Button } from "./Form"; interface LoginFormProps { @@ -19,10 +20,17 @@ export default function LoginForm({ const [handle, setHandle] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const matches = useHandleSearch(handle); - const dropdown = useDropdown(matches.length, (index) => - selectHandle(matches[index].handle), + + const suggestions = useHandleSearch(handle); + const dropdown = useDropdown(suggestions.length, (index) => + selectHandle(suggestions[index].handle), ); + const showSuggestions = dropdown.focused && suggestions.length > 0; + + function selectHandle(selected: string) { + setHandle(selected); + dropdown.close(); + } async function onSubmit(event: SyntheticEvent) { event.preventDefault(); @@ -30,22 +38,16 @@ export default function LoginForm({ setBusy(true); try { await login(handle.trim()); - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Could not log in."); + } catch (err) { + console.error("Login failed:", err); + setError("Couldn't find that handle. Double-check the spelling?"); setBusy(false); } } - function selectHandle(selected: string) { - setHandle(selected); - dropdown.close(); - } - - const dropdownOpen = dropdown.focused && matches.length > 0; - return ( <> - {error &&

{error}

} + {error &&

{error}

}
= 0 ? `${idPrefix}-option-${dropdown.activeIndex}` @@ -73,45 +75,13 @@ export default function LoginForm({ {busy ? "..." : } - {dropdownOpen && ( -
-
- {matches.map((match, index) => ( - - ))} -
-
+ {showSuggestions && ( + )}
diff --git a/web/src/lib/atprotoApps.ts b/web/src/lib/atprotoApps.ts new file mode 100644 index 0000000..63a0dc3 --- /dev/null +++ b/web/src/lib/atprotoApps.ts @@ -0,0 +1,20 @@ +export interface AtprotoApp { + name: string; + url: string; +} + +export const ATPROTO_APPS: AtprotoApp[] = [ + { name: "Blacksky", url: "https://blacksky.community" }, + { name: "Bluesky", url: "https://bsky.app" }, + { name: "Grain Social", url: "https://grain.social" }, + { name: "Leaflet", url: "https://leaflet.pub" }, + { name: "pckt.blog", url: "https://pckt.blog" }, + { name: "Streamplace", url: "https://stream.place" }, + { name: "Tangled", url: "https://tangled.sh" }, + { name: "wisp.place", url: "https://wisp.place" }, +]; + +export function pickRandomApps(count: number): AtprotoApp[] { + const shuffled = [...ATPROTO_APPS].sort(() => Math.random() - 0.5); + return shuffled.slice(0, count); +} diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index 158cae4..ac21fcc 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -168,11 +168,11 @@ export function getCurrentUser(): AuthUser | null { // --- Login --- async function login(handle: string): Promise { - // Remember where to send the user after the OAuth round-trip, but - // never back to /login or /oauth/callback (that would loop). + // Remember where to send the user after the OAuth round-trip, but never + // back to /oauth/callback (that would loop). try { const here = window.location.pathname; - const dest = here === "/login" || here.startsWith("/oauth/") ? "/" : here; + const dest = here.startsWith("/oauth/") ? "/" : here; sessionStorage.setItem(POST_LOGIN_KEY, dest); } catch { // non-fatal diff --git a/web/src/lib/loginModal.tsx b/web/src/lib/loginModal.tsx index 89e22e2..49eec4a 100644 --- a/web/src/lib/loginModal.tsx +++ b/web/src/lib/loginModal.tsx @@ -2,9 +2,11 @@ import { createContext, useCallback, useContext, + useEffect, useState, type ReactNode, } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; interface LoginModalCtx { open: boolean; @@ -18,6 +20,22 @@ export function LoginModalProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const openLogin = useCallback(() => setOpen(true), []); const closeLogin = useCallback(() => setOpen(false), []); + + // Open the modal when we land on a URL with ?login=1 (auth-required loader + // redirects use this), then strip the param so refreshes don't re-trigger. + const location = useLocation(); + const navigate = useNavigate(); + useEffect(() => { + const params = new URLSearchParams(location.search); + if (params.get("login") !== "1") return; + setOpen(true); + params.delete("login"); + const remaining = params.toString(); + navigate(location.pathname + (remaining ? `?${remaining}` : ""), { + replace: true, + }); + }, [location.pathname, location.search, navigate]); + return ( {children} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx deleted file mode 100644 index f5337e3..0000000 --- a/web/src/pages/Login.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { MessageSquare, Pin, User, Monitor } from "lucide-react"; -import { usePageTitle } from "../hooks/usePageTitle"; -import LoginForm from "../components/form/LoginForm"; - -export default function Login() { - usePageTitle("Login — atbbs"); - - return ( -
-
- - - @bbs - -

- Use any{" "} - - AT Protocol - {" "} - account. -

-
- -
- -
- -
-

Once signed in, you can:

- -

- We'll redirect you to your provider to continue. -

-
-
- ); -} diff --git a/web/src/router/loaders/auth.ts b/web/src/router/loaders/auth.ts index 99a26f7..6e7d902 100644 --- a/web/src/router/loaders/auth.ts +++ b/web/src/router/loaders/auth.ts @@ -4,6 +4,6 @@ import { ensureAuthReady, getCurrentUser } from "../../lib/auth"; export async function requireAuth() { await ensureAuthReady(); const user = getCurrentUser(); - if (!user) throw redirect("/login"); + if (!user) throw redirect("/?login=1"); return user; } diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 62ccac3..0f4d75d 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -9,7 +9,6 @@ import Layout from "../components/layout/Layout"; import ErrorPage from "../components/ErrorPage"; import Home from "../pages/Home"; -import Login from "../pages/Login"; import OAuthCallback from "../pages/OAuthCallback"; import Profile from "../pages/Profile"; import BBS from "../pages/BBS"; @@ -38,7 +37,6 @@ const routes: RouteObject[] = [ errorElement: , children: [ { path: "/", loader: homeLoader, element: }, - { path: "/login", element: }, { path: "/oauth/callback", element: }, { path: "/account", loader: () => redirect("/") }, { diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 9b7048e..0138bd8 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +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"} \ No newline at end of file +{"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"} \ No newline at end of file