👁️
5
fork

Configure Feed

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

add account creation flow

+422 -15
+1 -1
src/components/Header.tsx
··· 12 12 const location = useLocation(); 13 13 14 14 const handleSignInClick = () => { 15 - if (location.pathname !== "/signin") { 15 + if (location.pathname !== "/signin" && location.pathname !== "/signup") { 16 16 sessionStorage.setItem(RETURN_TO_KEY, location.href); 17 17 } 18 18 };
+48
src/lib/pds-hosts.ts
··· 1 + export interface PdsHost { 2 + url: string; 3 + name: string; 4 + description: string; 5 + handles: string[]; 6 + learnMoreUrl?: string; 7 + privacyUrl?: string; 8 + tosUrl?: string; 9 + } 10 + 11 + export const PDS_HOSTS: PdsHost[] = [ 12 + { 13 + url: "https://selfhosted.social", 14 + name: "selfhosted.social", 15 + description: "Community-run server", 16 + handles: ["selfhosted.social"], 17 + privacyUrl: "https://selfhosted.social/legal#privacy-policy", 18 + tosUrl: "https://selfhosted.social/legal#terms-of-service", 19 + }, 20 + { 21 + url: "https://bsky.social", 22 + name: "Bluesky", 23 + description: "Bluesky's default host", 24 + handles: ["bsky.social"], 25 + privacyUrl: "https://bsky.social/about/support/privacy-policy", 26 + tosUrl: "https://bsky.social/about/support/tos", 27 + }, 28 + { 29 + url: "https://blacksky.app", 30 + name: "Blacksky", 31 + description: "Blacksky-run servers", 32 + handles: ["blacksky.app", "myatproto.social", "cryptoanarchy.network"], 33 + learnMoreUrl: 34 + "https://docs.blacksky.community/migrating-to-blacksky-pds-complete-guide#who-can-use-blacksky-services", 35 + privacyUrl: "https://blackskyweb.xyz/about/support/privacy-policy/", 36 + tosUrl: "https://blackskyweb.xyz/about/support/tos", 37 + }, 38 + { 39 + url: "https://pds.tophhie.cloud", 40 + name: "tophhie.cloud", 41 + description: "Open registration", 42 + handles: ["tophhie.cloud"], 43 + privacyUrl: "https://blog.tophhie.cloud/atproto-privacy-policy/", 44 + tosUrl: "https://blog.tophhie.cloud/atproto-tos/", 45 + }, 46 + ]; 47 + 48 + export const DEFAULT_PDS_HOST = PDS_HOSTS[0];
+21 -1
src/lib/useAuth.tsx
··· 22 22 session: Session | null; 23 23 agent: OAuthUserAgent | null; 24 24 signIn: (handle: string) => Promise<void>; 25 + signUp: (pdsUrl: string) => Promise<void>; 25 26 signOut: () => Promise<void>; 26 27 setAuthSession: (session: Session) => void; 27 28 isLoading: boolean; ··· 78 79 window.location.assign(authUrl); 79 80 }; 80 81 82 + const signUp = async (pdsUrl: string) => { 83 + // TODO: add `prompt: "create"` when more PDS implementations support it 84 + // to hint that signup UI should be shown instead of login 85 + const authUrl = await createAuthorizationUrl({ 86 + target: { type: "pds", serviceUrl: pdsUrl }, 87 + scope: import.meta.env.VITE_OAUTH_SCOPE, 88 + }); 89 + 90 + window.location.assign(authUrl); 91 + }; 92 + 81 93 const signOut = async () => { 82 94 if (agent) { 83 95 await agent.signOut(); ··· 95 107 96 108 return ( 97 109 <AuthContext.Provider 98 - value={{ session, agent, signIn, signOut, isLoading, setAuthSession }} 110 + value={{ 111 + session, 112 + agent, 113 + signIn, 114 + signUp, 115 + signOut, 116 + isLoading, 117 + setAuthSession, 118 + }} 99 119 > 100 120 {children} 101 121 </AuthContext.Provider>
+21
src/routeTree.gen.ts
··· 9 9 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 + import { Route as SignupRouteImport } from './routes/signup' 12 13 import { Route as SigninRouteImport } from './routes/signin' 13 14 import { Route as DevRouteRouteImport } from './routes/dev/route' 14 15 import { Route as IndexRouteImport } from './routes/index' ··· 28 29 import { Route as ProfileDidDeckRkeyPlayRouteImport } from './routes/profile/$did/deck/$rkey/play' 29 30 import { Route as ProfileDidDeckRkeyBulkEditRouteImport } from './routes/profile/$did/deck/$rkey/bulk-edit' 30 31 32 + const SignupRoute = SignupRouteImport.update({ 33 + id: '/signup', 34 + path: '/signup', 35 + getParentRoute: () => rootRouteImport, 36 + } as any) 31 37 const SigninRoute = SigninRouteImport.update({ 32 38 id: '/signin', 33 39 path: '/signin', ··· 124 130 '/': typeof IndexRoute 125 131 '/dev': typeof DevRouteRouteWithChildren 126 132 '/signin': typeof SigninRoute 133 + '/signup': typeof SignupRoute 127 134 '/card/$id': typeof CardIdRoute 128 135 '/deck/new': typeof DeckNewRoute 129 136 '/dev/migrate': typeof DevMigrateRoute ··· 144 151 '/': typeof IndexRoute 145 152 '/dev': typeof DevRouteRouteWithChildren 146 153 '/signin': typeof SigninRoute 154 + '/signup': typeof SignupRoute 147 155 '/card/$id': typeof CardIdRoute 148 156 '/deck/new': typeof DeckNewRoute 149 157 '/dev/migrate': typeof DevMigrateRoute ··· 163 171 '/': typeof IndexRoute 164 172 '/dev': typeof DevRouteRouteWithChildren 165 173 '/signin': typeof SigninRoute 174 + '/signup': typeof SignupRoute 166 175 '/card/$id': typeof CardIdRoute 167 176 '/deck/new': typeof DeckNewRoute 168 177 '/dev/migrate': typeof DevMigrateRoute ··· 185 194 | '/' 186 195 | '/dev' 187 196 | '/signin' 197 + | '/signup' 188 198 | '/card/$id' 189 199 | '/deck/new' 190 200 | '/dev/migrate' ··· 205 215 | '/' 206 216 | '/dev' 207 217 | '/signin' 218 + | '/signup' 208 219 | '/card/$id' 209 220 | '/deck/new' 210 221 | '/dev/migrate' ··· 223 234 | '/' 224 235 | '/dev' 225 236 | '/signin' 237 + | '/signup' 226 238 | '/card/$id' 227 239 | '/deck/new' 228 240 | '/dev/migrate' ··· 244 256 IndexRoute: typeof IndexRoute 245 257 DevRouteRoute: typeof DevRouteRouteWithChildren 246 258 SigninRoute: typeof SigninRoute 259 + SignupRoute: typeof SignupRoute 247 260 CardIdRoute: typeof CardIdRoute 248 261 DeckNewRoute: typeof DeckNewRoute 249 262 OauthCallbackRoute: typeof OauthCallbackRoute ··· 257 270 258 271 declare module '@tanstack/react-router' { 259 272 interface FileRoutesByPath { 273 + '/signup': { 274 + id: '/signup' 275 + path: '/signup' 276 + fullPath: '/signup' 277 + preLoaderRoute: typeof SignupRouteImport 278 + parentRoute: typeof rootRouteImport 279 + } 260 280 '/signin': { 261 281 id: '/signin' 262 282 path: '/signin' ··· 430 450 IndexRoute: IndexRoute, 431 451 DevRouteRoute: DevRouteRouteWithChildren, 432 452 SigninRoute: SigninRoute, 453 + SignupRoute: SignupRoute, 433 454 CardIdRoute: CardIdRoute, 434 455 DeckNewRoute: DeckNewRoute, 435 456 OauthCallbackRoute: OauthCallbackRoute,
+10 -11
src/routes/signin.tsx
··· 1 1 import { useQuery } from "@tanstack/react-query"; 2 - import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 3 3 import { LogIn } from "lucide-react"; 4 4 import { useEffect, useId, useRef, useState } from "react"; 5 5 import { searchActorsQueryOptions } from "@/lib/actor-search"; ··· 252 252 )} 253 253 </button> 254 254 </form> 255 - </div> 256 255 257 - <div className="max-w-md w-full mt-6"> 258 - <div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"> 259 - <p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"> 260 - For example, if you have a Bluesky account, enter the handle of that 261 - account! You'll use that same identity and handle on Deckbelcher. 262 - Bluesky and Deckbelcher are both built on AT Protocol—there's a 263 - whole Atmosphere of other apps that can interact. 264 - </p> 265 - </div> 256 + <p className="mt-6 text-center text-gray-600 dark:text-gray-400"> 257 + Don't have an account?{" "} 258 + <Link 259 + to="/signup" 260 + className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300 font-medium" 261 + > 262 + Create one 263 + </Link> 264 + </p> 266 265 </div> 267 266 </div> 268 267 );
+317
src/routes/signup.tsx
··· 1 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 2 + import { ChevronDown, Info, UserPlus } from "lucide-react"; 3 + import { useId, useState } from "react"; 4 + import { DEFAULT_PDS_HOST, PDS_HOSTS } from "@/lib/pds-hosts"; 5 + import { RETURN_TO_KEY, useAuth } from "@/lib/useAuth"; 6 + 7 + export const Route = createFileRoute("/signup")({ 8 + component: SignUp, 9 + head: () => ({ 10 + meta: [{ title: "Create Account | DeckBelcher" }], 11 + }), 12 + }); 13 + 14 + function SignUp() { 15 + const [selectedPds, setSelectedPds] = useState(DEFAULT_PDS_HOST.url); 16 + const [customPdsUrl, setCustomPdsUrl] = useState(""); 17 + const [isSigningUp, setIsSigningUp] = useState(false); 18 + const [showOtherHosts, setShowOtherHosts] = useState(false); 19 + const [acknowledgedPolicy, setAcknowledgedPolicy] = useState(false); 20 + const { signUp, session } = useAuth(); 21 + const navigate = useNavigate(); 22 + const customPdsId = useId(); 23 + 24 + const otherHosts = PDS_HOSTS.slice(1); 25 + const selectedHost = PDS_HOSTS.find((h) => h.url === selectedPds); 26 + const needsAcknowledgment = selectedHost?.learnMoreUrl != null; 27 + 28 + const handleSignUp = async (e: React.FormEvent) => { 29 + e.preventDefault(); 30 + const pdsUrl = selectedPds === "custom" ? customPdsUrl.trim() : selectedPds; 31 + if (!pdsUrl || isSigningUp) return; 32 + 33 + setIsSigningUp(true); 34 + try { 35 + await signUp(pdsUrl); 36 + } catch (error) { 37 + console.error("Sign up error:", error); 38 + setIsSigningUp(false); 39 + } 40 + }; 41 + 42 + if (session) { 43 + const returnTo = sessionStorage.getItem(RETURN_TO_KEY); 44 + sessionStorage.removeItem(RETURN_TO_KEY); 45 + navigate({ to: returnTo || "/", replace: true }); 46 + return null; 47 + } 48 + 49 + return ( 50 + <div className="min-h-screen flex flex-col items-center justify-center bg-white dark:bg-slate-900 px-4 py-8"> 51 + <div className="max-w-md w-full bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-700 rounded-lg shadow-lg p-8"> 52 + <div className="flex items-center justify-center mb-6"> 53 + <div className="p-3 bg-emerald-600 rounded-full"> 54 + <UserPlus size={32} className="text-white" /> 55 + </div> 56 + </div> 57 + <h1 className="text-3xl font-bold text-gray-900 dark:text-white text-center mb-2 font-display"> 58 + Create Account 59 + </h1> 60 + <p className="text-gray-600 dark:text-gray-400 text-center mb-6"> 61 + New to the Atmosphere? Create an account on a PDS host. 62 + </p> 63 + 64 + <form onSubmit={handleSignUp}> 65 + {/* Default host - always visible */} 66 + <label 67 + className={`block mb-4 p-4 rounded-lg border-2 cursor-pointer transition-colors ${ 68 + selectedPds === DEFAULT_PDS_HOST.url 69 + ? "border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20" 70 + : "border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800" 71 + }`} 72 + > 73 + <div className="flex items-center gap-3"> 74 + <input 75 + type="radio" 76 + name="pds-host" 77 + value={DEFAULT_PDS_HOST.url} 78 + checked={selectedPds === DEFAULT_PDS_HOST.url} 79 + onChange={(e) => { 80 + setSelectedPds(e.target.value); 81 + setAcknowledgedPolicy(false); 82 + }} 83 + className="text-emerald-600 focus:ring-emerald-500" 84 + /> 85 + <div className="flex-1"> 86 + <div className="font-medium text-gray-900 dark:text-white"> 87 + {DEFAULT_PDS_HOST.name} 88 + </div> 89 + <div className="text-sm text-gray-600 dark:text-gray-400"> 90 + {DEFAULT_PDS_HOST.description} 91 + </div> 92 + </div> 93 + </div> 94 + </label> 95 + 96 + {/* Other hosts - collapsible */} 97 + <div className="border-t border-gray-200 dark:border-gray-700 pt-4 mb-4"> 98 + <button 99 + type="button" 100 + onClick={() => setShowOtherHosts(!showOtherHosts)} 101 + className="w-full flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white" 102 + > 103 + <span>Other hosting options</span> 104 + <ChevronDown 105 + size={16} 106 + className={`motion-safe:transition-transform ${showOtherHosts ? "rotate-180" : ""}`} 107 + /> 108 + </button> 109 + 110 + {showOtherHosts && ( 111 + <div className="mt-3 space-y-2"> 112 + {otherHosts.map((host) => ( 113 + <label 114 + key={host.url} 115 + className={`block p-3 rounded-lg border cursor-pointer transition-colors ${ 116 + selectedPds === host.url 117 + ? "border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20" 118 + : "border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800" 119 + }`} 120 + > 121 + <div className="flex items-center gap-3"> 122 + <input 123 + type="radio" 124 + name="pds-host" 125 + value={host.url} 126 + checked={selectedPds === host.url} 127 + onChange={(e) => { 128 + setSelectedPds(e.target.value); 129 + setAcknowledgedPolicy(false); 130 + }} 131 + className="text-emerald-600 focus:ring-emerald-500" 132 + /> 133 + <div className="flex-1"> 134 + <div className="font-medium text-gray-900 dark:text-white"> 135 + {host.name} 136 + </div> 137 + <div className="text-sm text-gray-600 dark:text-gray-400"> 138 + {host.description} 139 + </div> 140 + </div> 141 + </div> 142 + </label> 143 + ))} 144 + <label 145 + className={`block p-3 rounded-lg border cursor-pointer transition-colors ${ 146 + selectedPds === "custom" 147 + ? "border-emerald-500 bg-emerald-50 dark:bg-emerald-900/20" 148 + : "border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800" 149 + }`} 150 + > 151 + <div className="flex items-center gap-3"> 152 + <input 153 + type="radio" 154 + name="pds-host" 155 + value="custom" 156 + checked={selectedPds === "custom"} 157 + onChange={(e) => { 158 + setSelectedPds(e.target.value); 159 + setAcknowledgedPolicy(false); 160 + }} 161 + className="text-emerald-600 focus:ring-emerald-500" 162 + /> 163 + <div className="flex-1"> 164 + <div className="font-medium text-gray-900 dark:text-white"> 165 + Other... 166 + </div> 167 + <div className="text-sm text-gray-600 dark:text-gray-400"> 168 + Enter a custom PDS URL 169 + </div> 170 + </div> 171 + </div> 172 + </label> 173 + 174 + {selectedPds === "custom" && ( 175 + <div className="ml-6 mt-2"> 176 + <label 177 + htmlFor={customPdsId} 178 + className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" 179 + > 180 + PDS URL 181 + </label> 182 + <input 183 + id={customPdsId} 184 + type="url" 185 + value={customPdsUrl} 186 + onChange={(e) => setCustomPdsUrl(e.target.value)} 187 + placeholder="https://pds.example.com" 188 + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-emerald-600" 189 + required 190 + /> 191 + </div> 192 + )} 193 + </div> 194 + )} 195 + </div> 196 + 197 + {/* Acknowledgment checkbox for hosts with policy links */} 198 + {needsAcknowledgment && ( 199 + <label className="flex items-start gap-3 mb-4 p-3 bg-amber-50 dark:bg-slate-800 border border-amber-200 dark:border-amber-700/50 rounded-lg cursor-pointer"> 200 + <input 201 + type="checkbox" 202 + checked={acknowledgedPolicy} 203 + onChange={(e) => setAcknowledgedPolicy(e.target.checked)} 204 + className="mt-0.5 text-amber-600 focus:ring-amber-500" 205 + /> 206 + <span className="text-sm text-gray-700 dark:text-gray-300"> 207 + I've read the{" "} 208 + <a 209 + href={selectedHost?.learnMoreUrl} 210 + target="_blank" 211 + rel="noopener noreferrer" 212 + className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 underline" 213 + onClick={(e) => e.stopPropagation()} 214 + > 215 + handle policy 216 + </a>{" "} 217 + and will choose an appropriate handle during signup. 218 + </span> 219 + </label> 220 + )} 221 + 222 + <button 223 + type="submit" 224 + disabled={ 225 + isSigningUp || 226 + (selectedPds === "custom" && !customPdsUrl.trim()) || 227 + (needsAcknowledgment && !acknowledgedPolicy) 228 + } 229 + className="w-full px-4 py-3 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white rounded-lg transition-colors font-medium text-lg flex items-center justify-center gap-2" 230 + > 231 + {isSigningUp ? ( 232 + <> 233 + <div className="inline-block h-5 w-5 animate-spin rounded-full border-3 border-solid border-white border-r-transparent" /> 234 + <span>Redirecting...</span> 235 + </> 236 + ) : selectedPds === DEFAULT_PDS_HOST.url ? ( 237 + "Create Account" 238 + ) : ( 239 + <>Create Account on {selectedHost?.name || "Custom PDS"}</> 240 + )} 241 + </button> 242 + </form> 243 + 244 + {selectedHost && selectedPds !== "custom" && ( 245 + <div className="mt-4 p-3 bg-gray-50 dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg"> 246 + <div className="text-sm text-gray-600 dark:text-gray-400"> 247 + Available handles on {selectedHost.name}: 248 + </div> 249 + <div className="mt-1.5 flex flex-wrap gap-1"> 250 + {selectedHost.handles.map((handle) => ( 251 + <span 252 + key={handle} 253 + className="px-2 py-0.5 text-xs bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full" 254 + > 255 + .{handle} 256 + </span> 257 + ))} 258 + <span className="px-2 py-0.5 text-xs bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300 rounded-full"> 259 + custom domain 260 + </span> 261 + </div> 262 + </div> 263 + )} 264 + 265 + <div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg flex gap-2"> 266 + <Info 267 + size={16} 268 + className="text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" 269 + /> 270 + <p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"> 271 + Each host has its own policies and backup systems. Your data lives 272 + on the host you choose, but you can migrate later. 273 + {selectedHost && 274 + (selectedHost.tosUrl || selectedHost.privacyUrl) && ( 275 + <> 276 + {" "} 277 + Read {selectedHost.name}'s{" "} 278 + {selectedHost.tosUrl && ( 279 + <a 280 + href={selectedHost.tosUrl} 281 + target="_blank" 282 + rel="noopener noreferrer" 283 + className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 underline" 284 + > 285 + terms of service 286 + </a> 287 + )} 288 + {selectedHost.tosUrl && selectedHost.privacyUrl && " and "} 289 + {selectedHost.privacyUrl && ( 290 + <a 291 + href={selectedHost.privacyUrl} 292 + target="_blank" 293 + rel="noopener noreferrer" 294 + className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 underline" 295 + > 296 + privacy policy 297 + </a> 298 + )} 299 + . 300 + </> 301 + )} 302 + </p> 303 + </div> 304 + 305 + <p className="mt-6 text-center text-gray-600 dark:text-gray-400"> 306 + Already have an account?{" "} 307 + <Link 308 + to="/signin" 309 + className="text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300 font-medium" 310 + > 311 + Sign in 312 + </Link> 313 + </p> 314 + </div> 315 + </div> 316 + ); 317 + }
+4 -2
src/styles.css
··· 66 66 67 67 html { 68 68 @apply bg-white dark:bg-slate-900; 69 + color-scheme: light; 69 70 } 70 71 71 72 body { ··· 80 81 font-variation-settings: "MONO" 1, "CASL" 0; 81 82 } 82 83 83 - /* Dark mode scrollbar styling */ 84 + /* Dark mode native control & scrollbar styling */ 84 85 .dark { 85 - /* Firefox */ 86 + color-scheme: dark; 87 + /* Firefox scrollbar */ 86 88 scrollbar-color: var(--color-gray-600) var(--color-gray-800); 87 89 } 88 90