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:
+
+
+ {apps.map((app) => (
+ -
+ •
+ {app.name}
+
+
+
+
+ ))}
+
+
+ );
+}
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 (
-
-
-
-
-
-
-
-
-
Once signed in, you can:
-
- -
- Post threads and replies
-
- -
- Pin boards you like
-
- -
- Set up a profile
-
- -
- Start your own community
-
-
-
- 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