the statusphere demo reworked into a vite/react app in a monorepo

better form

+71 -18
+9 -1
packages/client/src/hooks/useAuth.tsx
··· 75 75 setError(null) 76 76 77 77 try { 78 - const result = await api.login(handle) 78 + // Add a small artificial delay for UX purposes 79 + const loginPromise = api.login(handle) 80 + 81 + // Ensure the loading state shows for at least 800ms for better UX 82 + const result = await Promise.all([ 83 + loginPromise, 84 + new Promise(resolve => setTimeout(resolve, 800)) 85 + ]).then(([loginResult]) => loginResult) 86 + 79 87 return result 80 88 } catch (err) { 81 89 const message = err instanceof Error ? err.message : 'Login failed'
+62 -17
packages/client/src/pages/LoginPage.tsx
··· 1 1 import { useState } from 'react' 2 2 import { Link } from 'react-router-dom' 3 + import { useMutation } from '@tanstack/react-query' 3 4 4 5 import Header from '#/components/Header' 5 6 import { useAuth } from '#/hooks/useAuth' ··· 7 8 const LoginPage = () => { 8 9 const [handle, setHandle] = useState('') 9 10 const [error, setError] = useState<string | null>(null) 10 - const { login, loading } = useAuth() 11 + const { login } = useAuth() 11 12 12 - const handleSubmit = async (e: React.FormEvent) => { 13 + const mutation = useMutation({ 14 + mutationFn: async (handleValue: string) => { 15 + const result = await login(handleValue) 16 + 17 + // Add a small delay before redirecting for better UX 18 + await new Promise((resolve) => setTimeout(resolve, 500)) 19 + 20 + return result 21 + }, 22 + onSuccess: (data) => { 23 + // Redirect to ATProto OAuth flow 24 + window.location.href = data.redirectUrl 25 + }, 26 + onError: (err) => { 27 + const message = err instanceof Error ? err.message : 'Login failed' 28 + setError(message) 29 + }, 30 + }) 31 + 32 + const handleSubmit = (e: React.FormEvent) => { 13 33 e.preventDefault() 34 + setError(null) 14 35 15 36 if (!handle.trim()) { 16 37 setError('Handle cannot be empty') 17 38 return 18 39 } 19 40 20 - try { 21 - const { redirectUrl } = await login(handle) 22 - // Redirect to ATProto OAuth flow 23 - window.location.href = redirectUrl 24 - } catch (err) { 25 - const message = err instanceof Error ? err.message : 'Login failed' 26 - setError(message) 27 - } 41 + mutation.mutate(handle) 28 42 } 43 + 44 + // count success as also pending, since the browser should be redirecting 45 + const pending = mutation.isPending || mutation.isSuccess 29 46 30 47 return ( 31 48 <div className="flex flex-col gap-8"> ··· 42 59 43 60 <form onSubmit={handleSubmit}> 44 61 <div className="mb-4"> 45 - <label htmlFor="handle" className="block mb-2 text-gray-700 dark:text-gray-300"> 62 + <label 63 + htmlFor="handle" 64 + className="block mb-2 text-gray-700 dark:text-gray-300" 65 + > 46 66 Enter your Bluesky handle: 47 67 </label> 48 68 <input ··· 51 71 value={handle} 52 72 onChange={(e) => setHandle(e.target.value)} 53 73 placeholder="example.bsky.social" 54 - disabled={loading} 55 - className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500" 74 + disabled={pending} 75 + className="w-full p-3 border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 transition-colors" 56 76 /> 57 77 </div> 58 78 59 79 <button 60 80 type="submit" 61 - disabled={loading} 62 - className={`w-full px-4 py-2 rounded-md bg-blue-500 dark:bg-blue-600 text-white font-medium hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 ${ 63 - loading ? 'opacity-70 cursor-not-allowed' : '' 81 + disabled={pending} 82 + className={`w-full px-4 py-2 rounded-md bg-blue-500 dark:bg-blue-600 text-white font-medium hover:bg-blue-600 dark:hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-500 relative ${ 83 + pending ? 'opacity-90 cursor-not-allowed' : '' 64 84 }`} 65 85 > 66 - {loading ? 'Logging in...' : 'Login'} 86 + <span className={pending ? 'invisible' : ''}>Login</span> 87 + {pending && ( 88 + <span className="absolute inset-0 flex items-center justify-center"> 89 + <svg 90 + className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" 91 + xmlns="http://www.w3.org/2000/svg" 92 + fill="none" 93 + viewBox="0 0 24 24" 94 + > 95 + <circle 96 + className="opacity-25" 97 + cx="12" 98 + cy="12" 99 + r="10" 100 + stroke="currentColor" 101 + strokeWidth="4" 102 + ></circle> 103 + <path 104 + className="opacity-75" 105 + fill="currentColor" 106 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 107 + ></path> 108 + </svg> 109 + <span>Connecting...</span> 110 + </span> 111 + )} 67 112 </button> 68 113 </form> 69 114