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}