The Appview for the kipclip.com atproto bookmarking service
2
fork

Configure Feed

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

at main 472 lines 16 kB view raw
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}