Graphical PDS migrator for AT Protocol
1import { useState } from "preact/hooks"; 2import { JSX } from "preact"; 3 4/** 5 * The OAuth handle input form. 6 * @returns The handle input form 7 * @component 8 */ 9export default function HandleInput() { 10 const [handle, setHandle] = useState(""); 11 const [error, setError] = useState<string | null>(null); 12 const [isPending, setIsPending] = useState(false); 13 14 const handleSubmit = async (e: JSX.TargetedEvent<HTMLFormElement>) => { 15 e.preventDefault(); 16 if (!handle.trim()) return; 17 18 setError(null); 19 setIsPending(true); 20 21 try { 22 const response = await fetch("/api/oauth/initiate", { 23 method: "POST", 24 headers: { 25 "Content-Type": "application/json", 26 }, 27 body: JSON.stringify({ handle }), 28 }); 29 30 if (!response.ok) { 31 const errorText = await response.text(); 32 throw new Error(errorText || "Login failed"); 33 } 34 35 const data = await response.json(); 36 37 // Add a small delay before redirecting for better UX 38 await new Promise((resolve) => setTimeout(resolve, 500)); 39 40 // Redirect to ATProto OAuth flow 41 globalThis.location.href = data.redirectUrl; 42 } catch (err) { 43 const message = err instanceof Error ? err.message : "Login failed"; 44 setError(message); 45 } finally { 46 setIsPending(false); 47 } 48 }; 49 50 return ( 51 <form onSubmit={handleSubmit}> 52 {error && ( 53 <div className="text-red-500 mb-4 p-2 bg-red-50 dark:bg-red-950 dark:bg-opacity-30 rounded-md"> 54 {error} 55 </div> 56 )} 57 58 <div className="mb-4"> 59 <label 60 htmlFor="handle" 61 className="block mb-2 text-gray-700 dark:text-gray-300" 62 > 63 Enter your Bluesky handle: 64 </label> 65 <input 66 id="handle" 67 type="text" 68 value={handle} 69 onInput={(e) => setHandle((e.target as HTMLInputElement).value)} 70 placeholder="example.bsky.social" 71 disabled={isPending} 72 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" 73 /> 74 <p className="text-gray-400 dark:text-gray-500 text-sm mt-2"> 75 You can also enter an AT Protocol PDS URL, i.e.{" "} 76 <span className="whitespace-nowrap">https://bsky.social</span> 77 </p> 78 </div> 79 80 <button 81 type="submit" 82 disabled={isPending} 83 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 ${ 84 isPending ? "opacity-90 cursor-not-allowed" : "" 85 }`} 86 > 87 <span className={isPending ? "invisible" : ""}>Login</span> 88 {isPending && ( 89 <span className="absolute inset-0 flex items-center justify-center"> 90 <svg 91 className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" 92 xmlns="http://www.w3.org/2000/svg" 93 fill="none" 94 viewBox="0 0 24 24" 95 > 96 <circle 97 className="opacity-25" 98 cx="12" 99 cy="12" 100 r="10" 101 stroke="currentColor" 102 strokeWidth="4" 103 > 104 </circle> 105 <path 106 className="opacity-75" 107 fill="currentColor" 108 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" 109 > 110 </path> 111 </svg> 112 <span>Connecting...</span> 113 </span> 114 )} 115 </button> 116 </form> 117 ); 118}