The Appview for the kipclip.com atproto bookmarking service
1import { useEffect, useRef, useState } from "react";
2import { isStandalonePwa, openOAuthPopup } from "../utils/pwa.ts";
3import { Button } from "./Button.tsx";
4import {
5 getSavedIdentities,
6 removeIdentity,
7 type SavedIdentity,
8 updateIdentityAvatar,
9} from "../utils/saved-identities.ts";
10
11/**
12 * Hue from a handle — stable per-handle color for the initial-avatar fallback.
13 * Keeps the saved-identity row visually distinct without shipping 1:1 avatars.
14 */
15function hueFromHandle(handle: string): number {
16 let h = 0;
17 for (let i = 0; i < handle.length; i++) {
18 h = (h * 31 + handle.charCodeAt(i)) >>> 0;
19 }
20 return h % 360;
21}
22
23function IdentityAvatar(
24 { handle, avatar, size = 40 }: {
25 handle: string;
26 avatar?: string;
27 size?: number;
28 },
29) {
30 const [failed, setFailed] = useState(false);
31 const initial = handle.replace(/^@/, "").charAt(0).toUpperCase() || "?";
32 const hue = hueFromHandle(handle);
33
34 if (avatar && !failed) {
35 return (
36 <img
37 src={avatar}
38 alt=""
39 width={size}
40 height={size}
41 className="rounded-full object-cover shrink-0"
42 style={{ width: size, height: size }}
43 onError={() => setFailed(true)}
44 />
45 );
46 }
47
48 return (
49 <div
50 className="rounded-full flex items-center justify-center font-semibold text-white shrink-0"
51 style={{
52 width: size,
53 height: size,
54 backgroundColor: `hsl(${hue}, 45%, 55%)`,
55 fontSize: size * 0.4,
56 }}
57 aria-hidden
58 >
59 {initial}
60 </div>
61 );
62}
63
64/**
65 * Validate an AT Protocol handle format.
66 * Valid formats:
67 * - user.bsky.social
68 * - example.com
69 * - subdomain.example.com
70 */
71function validateHandle(handle: string): { valid: boolean; error?: string } {
72 const trimmed = handle.trim();
73
74 if (!trimmed) {
75 return { valid: false, error: "Handle is required" };
76 }
77
78 // Handle must contain at least one dot
79 if (!trimmed.includes(".")) {
80 return {
81 valid: false,
82 error: "Handle must include a domain (e.g., alice.bsky.social)",
83 };
84 }
85
86 // Basic format check: alphanumeric, dots, hyphens only
87 const validPattern = /^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$/;
88 if (!validPattern.test(trimmed)) {
89 return {
90 valid: false,
91 error: "Handle contains invalid characters",
92 };
93 }
94
95 // Check for consecutive dots or dots at start/end (already handled by pattern above)
96 if (trimmed.includes("..")) {
97 return {
98 valid: false,
99 error: "Handle cannot contain consecutive dots",
100 };
101 }
102
103 return { valid: true };
104}
105
106export function Login() {
107 const [handle, setHandle] = useState("");
108 const [loading, setLoading] = useState(false);
109 const [error, setError] = useState<string | null>(null);
110 const inputRef = useRef<HTMLInputElement>(null);
111 const [savedIdentities, setSavedIdentities] = useState<SavedIdentity[]>(
112 getSavedIdentities,
113 );
114 const [showForm, setShowForm] = useState(savedIdentities.length === 0);
115
116 // Sync handle state when the Web Component updates the input value
117 useEffect(() => {
118 const input = inputRef.current;
119 if (!input) return;
120
121 // The actor-typeahead component sets input.value directly,
122 // so we need to listen for input events to sync React state
123 const handleInput = () => {
124 setHandle(input.value);
125 };
126
127 // Ensure button state reflects any prefilled value when the form appears
128 handleInput();
129
130 input.addEventListener("input", handleInput);
131 return () => input.removeEventListener("input", handleInput);
132 }, [showForm]);
133
134 // Fetch avatars for saved identities that don't have one cached yet.
135 useEffect(() => {
136 const missing = savedIdentities.filter((id) => !id.avatar);
137 if (missing.length === 0) return;
138
139 let cancelled = false;
140 for (const identity of missing) {
141 const url =
142 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${
143 encodeURIComponent(identity.handle)
144 }`;
145 fetch(url)
146 .then((r) => r.ok ? r.json() : null)
147 .then((data) => {
148 if (cancelled || !data?.avatar) return;
149 updateIdentityAvatar(identity.did, data.avatar);
150 setSavedIdentities((current) =>
151 current.map((id) =>
152 id.did === identity.did ? { ...id, avatar: data.avatar } : id
153 )
154 );
155 })
156 .catch(() => {/* fallback to initial avatar */});
157 }
158
159 return () => {
160 cancelled = true;
161 };
162 }, [savedIdentities.length]);
163
164 async function startOAuthFlow(handle: string) {
165 setLoading(true);
166 try {
167 const params = new URLSearchParams(globalThis.location.search);
168 const redirect = params.get("redirect");
169
170 let loginUrl = `/login?handle=${encodeURIComponent(handle)}`;
171 if (redirect) {
172 loginUrl += `&redirect=${encodeURIComponent(redirect)}`;
173 }
174
175 // PWA mode: use popup OAuth to avoid losing PWA context
176 if (isStandalonePwa()) {
177 loginUrl += "&pwa=true";
178 try {
179 await openOAuthPopup(loginUrl);
180 globalThis.location.reload();
181 } catch (popupError) {
182 const message = popupError instanceof Error
183 ? popupError.message
184 : "Login failed";
185 if (message !== "Login cancelled") {
186 setError(message);
187 }
188 setLoading(false);
189 }
190 return;
191 }
192
193 // Regular web mode: redirect to OAuth login
194 globalThis.location.href = loginUrl;
195 } catch (_error) {
196 console.error("Login failed:", _error);
197 setError("Login failed. Please try again.");
198 setLoading(false);
199 }
200 }
201
202 async function handleLogin(e: React.FormEvent) {
203 e.preventDefault();
204 setError(null);
205
206 // Read directly from input in case Web Component updated it without firing input event
207 const currentHandle = inputRef.current?.value || handle;
208 if (!currentHandle.trim()) return;
209
210 const trimmed = currentHandle.trim();
211
212 // Authorization server URLs are valid for initiating OAuth flows
213 if (!trimmed.startsWith("https://")) {
214 const validation = validateHandle(trimmed);
215 if (!validation.valid) {
216 setError(validation.error || "Invalid handle format");
217 return;
218 }
219 }
220
221 await startOAuthFlow(trimmed);
222 }
223
224 function handleBlueskyConnect() {
225 setError(null);
226 startOAuthFlow("https://bsky.social");
227 }
228
229 return (
230 <div className="min-h-screen flex items-center justify-center px-4">
231 <div className="max-w-md w-full">
232 <div className="text-center mb-8 fade-in">
233 <img
234 src="https://res.cloudinary.com/dru3aznlk/image/upload/v1760376452/kip-satchel-transparent_ewnh0j.png"
235 alt="kipclip mascot - a friendly chicken with a bookmark bag"
236 className="w-48 h-48 mx-auto mb-6 object-contain"
237 />
238 <h1
239 className="text-4xl font-bold mb-2"
240 style={{ color: "var(--coral)" }}
241 >
242 kipclip
243 </h1>
244 <p className="text-gray-600">
245 You find it, you kip it
246 </p>
247 </div>
248
249 <div className="card fade-in">
250 <h2 className="text-lg font-semibold text-gray-800 mb-4">
251 Connect with your Atmosphere account
252 </h2>
253
254 {savedIdentities.length > 0 && !showForm && (
255 <div className="space-y-3">
256 {savedIdentities.map((identity) => (
257 <div key={identity.did} className="relative group">
258 <button
259 type="button"
260 onClick={() => {
261 setError(null);
262 startOAuthFlow(identity.handle);
263 }}
264 disabled={loading}
265 className="w-full flex items-center gap-3 pl-3 pr-12 py-2.5 rounded-xl bg-white shadow-sm ring-1 ring-gray-200 hover:ring-2 hover:shadow-md hover:-translate-y-px transition-all text-left disabled:opacity-50 disabled:cursor-not-allowed"
266 style={{ transitionDuration: "150ms" }}
267 >
268 <IdentityAvatar
269 handle={identity.handle}
270 avatar={identity.avatar}
271 />
272 <div className="flex-1 min-w-0">
273 <div className="text-xs text-gray-500 leading-tight">
274 Continue as
275 </div>
276 <div className="font-semibold text-gray-800 truncate">
277 @{identity.handle}
278 </div>
279 </div>
280 <svg
281 className="w-5 h-5 text-gray-400 group-hover:text-gray-600 shrink-0"
282 fill="none"
283 viewBox="0 0 24 24"
284 strokeWidth={2}
285 stroke="currentColor"
286 aria-hidden
287 >
288 <path
289 strokeLinecap="round"
290 strokeLinejoin="round"
291 d="M9 5l7 7-7 7"
292 />
293 </svg>
294 </button>
295 <button
296 type="button"
297 onClick={(e) => {
298 e.stopPropagation();
299 removeIdentity(identity.did);
300 setSavedIdentities((current) => {
301 const updated = current.filter((id) =>
302 id.did !== identity.did
303 );
304 if (updated.length === 0) {
305 setShowForm(true);
306 }
307 return updated;
308 });
309 }}
310 className="absolute -top-1.5 -right-1.5 w-6 h-6 flex items-center justify-center rounded-full bg-white shadow ring-1 ring-gray-200 text-gray-400 hover:text-gray-700 hover:ring-gray-300 transition opacity-0 group-hover:opacity-100 focus:opacity-100"
311 aria-label={`Remove ${identity.handle}`}
312 >
313 <svg
314 className="w-3.5 h-3.5"
315 fill="none"
316 viewBox="0 0 24 24"
317 strokeWidth={2.5}
318 stroke="currentColor"
319 aria-hidden
320 >
321 <path
322 strokeLinecap="round"
323 strokeLinejoin="round"
324 d="M6 18L18 6M6 6l12 12"
325 />
326 </svg>
327 </button>
328 </div>
329 ))}
330
331 {error && (
332 <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
333 {error}
334 </div>
335 )}
336
337 {loading && (
338 <div className="flex items-center justify-center gap-2 text-gray-500 py-2">
339 <div className="spinner w-5 h-5 border-2"></div>
340 Connecting...
341 </div>
342 )}
343
344 <Button
345 variant="link"
346 size="sm"
347 fullWidth
348 onClick={() => setShowForm(true)}
349 >
350 Use a different account
351 </Button>
352 </div>
353 )}
354
355 {showForm && (
356 <form onSubmit={handleLogin} className="space-y-4">
357 <div>
358 <label
359 htmlFor="handle"
360 className="block text-sm font-medium text-gray-700 mb-2"
361 >
362 Handle
363 </label>
364 <actor-typeahead>
365 <input
366 ref={inputRef}
367 type="text"
368 id="handle"
369 autoComplete="off"
370 data-1p-ignore
371 data-lpignore="true"
372 data-form-type="other"
373 placeholder="alice.bsky.social or your-domain.com"
374 className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-coral focus:border-transparent outline-none transition"
375 disabled={loading}
376 autoFocus
377 />
378 </actor-typeahead>
379 </div>
380
381 {error && (
382 <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
383 {error}
384 </div>
385 )}
386
387 <Button
388 type="submit"
389 variant="primary"
390 fullWidth
391 loading={loading}
392 disabled={!handle.trim()}
393 >
394 {loading ? "Connecting..." : "Connect"}
395 </Button>
396
397 {savedIdentities.length > 0 && (
398 <Button
399 variant="link"
400 size="sm"
401 fullWidth
402 onClick={() => setShowForm(false)}
403 >
404 Back to saved accounts
405 </Button>
406 )}
407 </form>
408 )}
409
410 <details className="mt-4 text-sm text-gray-500">
411 <summary className="cursor-pointer font-medium text-gray-600 hover:text-gray-800">
412 What is an Atmosphere account?
413 </summary>
414 <div className="mt-2 space-y-2">
415 <p>
416 The Atmosphere is an open ecosystem of apps built on AT Protocol
417 — the same technology that powers Bluesky. When you create an
418 Atmosphere account, it works automatically across a growing
419 number of apps, including kipclip.
420 </p>
421 <p>
422 Your bookmarks are yours — stored in your own account, not on
423 our servers. If kipclip ever goes away, your data stays with
424 you.{" "}
425 <a href="/faq" className="underline hover:text-gray-700">
426 Learn more
427 </a>
428 </p>
429 </div>
430 </details>
431
432 <Button
433 href="/create-account"
434 variant="secondary"
435 fullWidth
436 className="mt-4"
437 >
438 Create a new account
439 </Button>
440
441 <div className="relative my-6">
442 <div className="absolute inset-0 flex items-center">
443 <div className="w-full border-t border-gray-200"></div>
444 </div>
445 <div className="relative flex justify-center text-sm">
446 <span className="px-3 bg-white text-gray-400">or</span>
447 </div>
448 </div>
449
450 <Button
451 variant="secondary"
452 fullWidth
453 onClick={handleBlueskyConnect}
454 disabled={loading}
455 leadingIcon={
456 <svg
457 className="w-5 h-5"
458 viewBox="0 0 568 501"
459 fill="#1185FF"
460 aria-hidden
461 >
462 <path d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 383.039C285.169 375.812 284.017 372.431 284 375.306C283.983 372.431 282.831 375.812 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0533 296.954 15.7778 224.501C9.94525 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z" />
463 </svg>
464 }
465 >
466 Connect with Bluesky
467 </Button>
468 </div>
469 </div>
470 </div>
471 );
472}