Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import React, { useState, useRef, useEffect } from 'react' 2import { createRoot } from 'react-dom/client' 3import { 4 ArrowRight, 5 Shield, 6 Zap, 7 Globe, 8 Lock, 9 Code, 10 Server 11} from 'lucide-react' 12import Layout from '@public/layouts' 13import { Button } from '@public/components/ui/button' 14import { Card } from '@public/components/ui/card' 15import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui' 16 17//Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead 18interface Actor { 19 handle: string 20 avatar?: string 21 displayName?: string 22} 23 24interface ActorTypeaheadProps { 25 children: React.ReactElement<React.InputHTMLAttributes<HTMLInputElement>> 26 host?: string 27 rows?: number 28 onSelect?: (handle: string) => void 29 autoSubmit?: boolean 30} 31 32const ActorTypeahead: React.FC<ActorTypeaheadProps> = ({ 33 children, 34 host = 'https://public.api.bsky.app', 35 rows = 5, 36 onSelect, 37 autoSubmit = false 38}) => { 39 const [actors, setActors] = useState<Actor[]>([]) 40 const [index, setIndex] = useState(-1) 41 const [pressed, setPressed] = useState(false) 42 const [isOpen, setIsOpen] = useState(false) 43 const containerRef = useRef<HTMLDivElement>(null) 44 const inputRef = useRef<HTMLInputElement>(null) 45 const lastQueryRef = useRef<string>('') 46 const previousValueRef = useRef<string>('') 47 const preserveIndexRef = useRef(false) 48 49 const handleInput = async (e: React.FormEvent<HTMLInputElement>) => { 50 const query = e.currentTarget.value 51 52 // Check if the value actually changed (filter out arrow key events) 53 if (query === previousValueRef.current) { 54 return 55 } 56 previousValueRef.current = query 57 58 if (!query) { 59 setActors([]) 60 setIndex(-1) 61 setIsOpen(false) 62 lastQueryRef.current = '' 63 return 64 } 65 66 // Store the query for this request 67 const currentQuery = query 68 lastQueryRef.current = currentQuery 69 70 try { 71 const url = new URL('xrpc/app.bsky.actor.searchActorsTypeahead', host) 72 url.searchParams.set('q', query) 73 url.searchParams.set('limit', `${rows}`) 74 75 const res = await fetch(url) 76 const json = await res.json() 77 78 // Only update if this is still the latest query 79 if (lastQueryRef.current === currentQuery) { 80 setActors(json.actors || []) 81 // Only reset index if we're not preserving it 82 if (!preserveIndexRef.current) { 83 setIndex(-1) 84 } 85 preserveIndexRef.current = false 86 setIsOpen(true) 87 } 88 } catch (error) { 89 console.error('Failed to fetch actors:', error) 90 if (lastQueryRef.current === currentQuery) { 91 setActors([]) 92 setIsOpen(false) 93 } 94 } 95 } 96 97 const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 98 const navigationKeys = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Enter', 'Escape'] 99 100 // Mark that we should preserve the index for navigation keys 101 if (navigationKeys.includes(e.key)) { 102 preserveIndexRef.current = true 103 } 104 105 if (!isOpen || actors.length === 0) return 106 107 switch (e.key) { 108 case 'ArrowDown': 109 e.preventDefault() 110 setIndex((prev) => { 111 const newIndex = prev < 0 ? 0 : Math.min(prev + 1, actors.length - 1) 112 return newIndex 113 }) 114 break 115 case 'PageDown': 116 e.preventDefault() 117 setIndex(actors.length - 1) 118 break 119 case 'ArrowUp': 120 e.preventDefault() 121 setIndex((prev) => { 122 const newIndex = prev < 0 ? 0 : Math.max(prev - 1, 0) 123 return newIndex 124 }) 125 break 126 case 'PageUp': 127 e.preventDefault() 128 setIndex(0) 129 break 130 case 'Escape': 131 e.preventDefault() 132 setActors([]) 133 setIndex(-1) 134 setIsOpen(false) 135 break 136 case 'Enter': 137 if (index >= 0 && index < actors.length) { 138 e.preventDefault() 139 selectActor(actors[index].handle) 140 } 141 break 142 } 143 } 144 145 const selectActor = (handle: string) => { 146 if (inputRef.current) { 147 inputRef.current.value = handle 148 } 149 setActors([]) 150 setIndex(-1) 151 setIsOpen(false) 152 onSelect?.(handle) 153 154 // Auto-submit the form if enabled 155 if (autoSubmit && inputRef.current) { 156 const form = inputRef.current.closest('form') 157 if (form) { 158 // Use setTimeout to ensure the value is set before submission 159 setTimeout(() => { 160 form.requestSubmit() 161 }, 0) 162 } 163 } 164 } 165 166 const handleFocusOut = (e: React.FocusEvent) => { 167 if (pressed) return 168 setActors([]) 169 setIndex(-1) 170 setIsOpen(false) 171 } 172 173 // Clone the input element and add our event handlers 174 const input = React.cloneElement(children, { 175 ref: (el: HTMLInputElement) => { 176 inputRef.current = el 177 // Preserve the original ref if it exists 178 const originalRef = (children as any).ref 179 if (typeof originalRef === 'function') { 180 originalRef(el) 181 } else if (originalRef) { 182 originalRef.current = el 183 } 184 }, 185 onInput: (e: React.FormEvent<HTMLInputElement>) => { 186 handleInput(e) 187 children.props.onInput?.(e) 188 }, 189 onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => { 190 handleKeyDown(e) 191 children.props.onKeyDown?.(e) 192 }, 193 onBlur: (e: React.FocusEvent<HTMLInputElement>) => { 194 handleFocusOut(e) 195 children.props.onBlur?.(e) 196 }, 197 autoComplete: 'off' 198 } as any) 199 200 return ( 201 <div ref={containerRef} style={{ position: 'relative', display: 'block' }}> 202 {input} 203 {isOpen && actors.length > 0 && ( 204 <ul 205 style={{ 206 display: 'flex', 207 flexDirection: 'column', 208 position: 'absolute', 209 left: 0, 210 marginTop: '4px', 211 width: '100%', 212 listStyle: 'none', 213 overflow: 'hidden', 214 backgroundColor: 'rgba(255, 255, 255, 0.8)', 215 backgroundClip: 'padding-box', 216 backdropFilter: 'blur(12px)', 217 WebkitBackdropFilter: 'blur(12px)', 218 border: '1px solid rgba(0, 0, 0, 0.1)', 219 borderRadius: '8px', 220 boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)', 221 padding: '4px', 222 margin: 0, 223 zIndex: 1000 224 }} 225 onMouseDown={() => setPressed(true)} 226 onMouseUp={() => { 227 setPressed(false) 228 inputRef.current?.focus() 229 }} 230 > 231 {actors.map((actor, i) => ( 232 <li key={actor.handle}> 233 <button 234 type="button" 235 onClick={() => selectActor(actor.handle)} 236 style={{ 237 all: 'unset', 238 boxSizing: 'border-box', 239 display: 'flex', 240 alignItems: 'center', 241 gap: '8px', 242 padding: '6px 8px', 243 width: '100%', 244 height: 'calc(1.5rem + 12px)', 245 borderRadius: '4px', 246 cursor: 'pointer', 247 backgroundColor: i === index ? 'hsl(var(--accent) / 0.5)' : 'transparent', 248 transition: 'background-color 0.1s' 249 }} 250 onMouseEnter={() => setIndex(i)} 251 > 252 <div 253 style={{ 254 width: '1.5rem', 255 height: '1.5rem', 256 borderRadius: '50%', 257 backgroundColor: 'hsl(var(--muted))', 258 overflow: 'hidden', 259 flexShrink: 0 260 }} 261 > 262 {actor.avatar && ( 263 <img 264 src={actor.avatar} 265 alt="" 266 style={{ 267 display: 'block', 268 width: '100%', 269 height: '100%', 270 objectFit: 'cover' 271 }} 272 /> 273 )} 274 </div> 275 <span 276 style={{ 277 whiteSpace: 'nowrap', 278 overflow: 'hidden', 279 textOverflow: 'ellipsis', 280 color: '#000000' 281 }} 282 > 283 {actor.handle} 284 </span> 285 </button> 286 </li> 287 ))} 288 </ul> 289 )} 290 </div> 291 ) 292} 293 294const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => { 295 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>( 296 did, 297 'app.bsky.feed.post' 298 ) 299 300 if (loading) return <span>Loading</span> 301 if (!record || !rkey) return <span>No posts yet.</span> 302 303 return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} /> 304} 305 306function App() { 307 const [showForm, setShowForm] = useState(false) 308 const [checkingAuth, setCheckingAuth] = useState(true) 309 const inputRef = useRef<HTMLInputElement>(null) 310 311 useEffect(() => { 312 // Check authentication status on mount 313 const checkAuth = async () => { 314 try { 315 const response = await fetch('/api/auth/status', { 316 credentials: 'include' 317 }) 318 const data = await response.json() 319 if (data.authenticated) { 320 // User is already authenticated, redirect to editor 321 window.location.href = '/editor' 322 return 323 } 324 // If not authenticated, clear any stale cookies 325 document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 326 } catch (error) { 327 console.error('Auth check failed:', error) 328 // Clear cookies on error as well 329 document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 330 } finally { 331 setCheckingAuth(false) 332 } 333 } 334 335 checkAuth() 336 }, []) 337 338 useEffect(() => { 339 if (showForm) { 340 setTimeout(() => inputRef.current?.focus(), 500) 341 } 342 }, [showForm]) 343 344 if (checkingAuth) { 345 return ( 346 <div className="min-h-screen bg-background flex items-center justify-center"> 347 <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div> 348 </div> 349 ) 350 } 351 352 return ( 353 <> 354 <div className="min-h-screen"> 355 {/* Header */} 356 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 357 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 358 <div className="flex items-center gap-2"> 359 <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 360 <span className="text-xl font-semibold text-foreground"> 361 wisp.place 362 </span> 363 </div> 364 <div className="flex items-center gap-3"> 365 <Button 366 variant="ghost" 367 size="sm" 368 onClick={() => setShowForm(true)} 369 > 370 Sign In 371 </Button> 372 <Button 373 size="sm" 374 className="bg-accent text-accent-foreground hover:bg-accent/90" 375 onClick={() => setShowForm(true)} 376 > 377 Get Started 378 </Button> 379 </div> 380 </div> 381 </header> 382 383 {/* Hero Section */} 384 <section className="container mx-auto px-4 py-20 md:py-32"> 385 <div className="max-w-4xl mx-auto text-center"> 386 <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8"> 387 <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span> 388 <span className="text-sm text-foreground"> 389 Built on AT Protocol 390 </span> 391 </div> 392 393 <h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight"> 394 Your Website.Your Control. Lightning Fast. 395 </h1> 396 397 <p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto"> 398 Host static sites in your AT Protocol account. You 399 keep ownership and control. We just serve them fast 400 through our CDN. 401 </p> 402 403 <div className="max-w-md mx-auto relative"> 404 <div 405 className={`transition-all duration-500 ease-in-out ${ 406 showForm 407 ? 'opacity-0 -translate-y-5 pointer-events-none' 408 : 'opacity-100 translate-y-0' 409 }`} 410 > 411 <Button 412 size="lg" 413 className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full" 414 onClick={() => setShowForm(true)} 415 > 416 Log in with AT Proto 417 <ArrowRight className="ml-2 w-5 h-5" /> 418 </Button> 419 </div> 420 421 <div 422 className={`transition-all duration-500 ease-in-out absolute inset-0 ${ 423 showForm 424 ? 'opacity-100 translate-y-0' 425 : 'opacity-0 translate-y-5 pointer-events-none' 426 }`} 427 > 428 <form 429 onSubmit={async (e) => { 430 e.preventDefault() 431 try { 432 const handle = 433 inputRef.current?.value 434 const res = await fetch( 435 '/api/auth/signin', 436 { 437 method: 'POST', 438 headers: { 439 'Content-Type': 440 'application/json' 441 }, 442 body: JSON.stringify({ 443 handle 444 }) 445 } 446 ) 447 if (!res.ok) 448 throw new Error( 449 'Request failed' 450 ) 451 const data = await res.json() 452 if (data.url) { 453 window.location.href = data.url 454 } else { 455 alert('Unexpected response') 456 } 457 } catch (error) { 458 console.error( 459 'Login failed:', 460 error 461 ) 462 // Clear any invalid cookies 463 document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 464 alert('Authentication failed') 465 } 466 }} 467 className="space-y-3" 468 > 469 <ActorTypeahead 470 autoSubmit={true} 471 onSelect={(handle) => { 472 if (inputRef.current) { 473 inputRef.current.value = handle 474 } 475 }} 476 > 477 <input 478 ref={inputRef} 479 type="text" 480 name="handle" 481 placeholder="Enter your handle (e.g., alice.bsky.social)" 482 className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 483 /> 484 </ActorTypeahead> 485 <button 486 type="submit" 487 className="w-full bg-accent hover:bg-accent/90 text-accent-foreground font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors" 488 > 489 Continue 490 <ArrowRight className="ml-2 w-5 h-5" /> 491 </button> 492 </form> 493 </div> 494 </div> 495 </div> 496 </section> 497 498 {/* How It Works */} 499 <section className="container mx-auto px-4 py-16 bg-muted/30"> 500 <div className="max-w-3xl mx-auto text-center"> 501 <h2 className="text-3xl md:text-4xl font-bold mb-8"> 502 How it works 503 </h2> 504 <div className="space-y-6 text-left"> 505 <div className="flex gap-4 items-start"> 506 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 507 01 508 </div> 509 <div> 510 <h3 className="text-xl font-semibold mb-2"> 511 Upload your static site 512 </h3> 513 <p className="text-muted-foreground"> 514 Your HTML, CSS, and JavaScript files are 515 stored in your AT Protocol account as 516 gzipped blobs and a manifest record. 517 </p> 518 </div> 519 </div> 520 <div className="flex gap-4 items-start"> 521 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 522 02 523 </div> 524 <div> 525 <h3 className="text-xl font-semibold mb-2"> 526 We serve it globally 527 </h3> 528 <p className="text-muted-foreground"> 529 Wisp.place reads your site from your 530 account and delivers it through our CDN 531 for fast loading anywhere. 532 </p> 533 </div> 534 </div> 535 <div className="flex gap-4 items-start"> 536 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 537 03 538 </div> 539 <div> 540 <h3 className="text-xl font-semibold mb-2"> 541 You stay in control 542 </h3> 543 <p className="text-muted-foreground"> 544 Update or remove your site anytime 545 through your AT Protocol account. No 546 lock-in, no middleman ownership. 547 </p> 548 </div> 549 </div> 550 </div> 551 </div> 552 </section> 553 554 {/* Features Grid */} 555 <section id="features" className="container mx-auto px-4 py-20"> 556 <div className="text-center mb-16"> 557 <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 558 Why Wisp.place? 559 </h2> 560 <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto"> 561 Static site hosting that respects your ownership 562 </p> 563 </div> 564 565 <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> 566 {[ 567 { 568 icon: Shield, 569 title: 'You Own Your Content', 570 description: 571 'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.' 572 }, 573 { 574 icon: Zap, 575 title: 'CDN Performance', 576 description: 577 'We cache and serve your site from edge locations worldwide for fast load times.' 578 }, 579 { 580 icon: Lock, 581 title: 'No Vendor Lock-in', 582 description: 583 'Your data stays in your account. Switch providers or self-host whenever you want.' 584 }, 585 { 586 icon: Code, 587 title: 'Simple Deployment', 588 description: 589 'Upload your static files and we handle the rest. No complex configuration needed.' 590 }, 591 { 592 icon: Server, 593 title: 'AT Protocol Native', 594 description: 595 'Built for the decentralized web. Your site has a verifiable identity on the network.' 596 }, 597 { 598 icon: Globe, 599 title: 'Custom Domains', 600 description: 601 'Use your own domain name or a wisp.place subdomain. Your choice, either way.' 602 } 603 ].map((feature, i) => ( 604 <Card 605 key={i} 606 className="p-6 hover:shadow-lg transition-shadow border-2 bg-card" 607 > 608 <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4"> 609 <feature.icon className="w-6 h-6 text-accent" /> 610 </div> 611 <h3 className="text-xl font-semibold mb-2 text-card-foreground"> 612 {feature.title} 613 </h3> 614 <p className="text-muted-foreground leading-relaxed"> 615 {feature.description} 616 </p> 617 </Card> 618 ))} 619 </div> 620 </section> 621 622 {/* CTA Section */} 623 <section className="container mx-auto px-4 py-20"> 624 <div className="max-w-6xl mx-auto"> 625 <div className="text-center mb-12"> 626 <h2 className="text-3xl md:text-4xl font-bold"> 627 Follow on Bluesky for updates 628 </h2> 629 </div> 630 <div className="grid md:grid-cols-2 gap-8 items-center"> 631 <Card 632 className="shadow-lg border-2 border-border overflow-hidden !py-3" 633 style={{ 634 '--atproto-color-bg': 'var(--card)', 635 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 636 '--atproto-color-text': 'hsl(var(--foreground))', 637 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 638 '--atproto-color-link': 'hsl(var(--accent))', 639 '--atproto-color-link-hover': 'hsl(var(--accent))', 640 '--atproto-color-border': 'transparent', 641 } as AtProtoStyles} 642 > 643 <BlueskyPostList did="wisp.place" /> 644 </Card> 645 <div className="space-y-6 w-full max-w-md mx-auto"> 646 <Card 647 className="shadow-lg border-2 overflow-hidden relative !py-3" 648 style={{ 649 '--atproto-color-bg': 'var(--card)', 650 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 651 '--atproto-color-text': 'hsl(var(--foreground))', 652 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 653 } as AtProtoStyles} 654 > 655 <BlueskyProfile did="wisp.place" /> 656 </Card> 657 <Card 658 className="shadow-lg border-2 overflow-hidden relative !py-3" 659 style={{ 660 '--atproto-color-bg': 'var(--card)', 661 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 662 '--atproto-color-text': 'hsl(var(--foreground))', 663 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 664 } as AtProtoStyles} 665 > 666 <LatestPostWithPrefetch did="wisp.place" /> 667 </Card> 668 </div> 669 </div> 670 </div> 671 </section> 672 673 {/* Ready to Deploy CTA */} 674 <section className="container mx-auto px-4 py-20"> 675 <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 676 <h2 className="text-3xl md:text-4xl font-bold mb-4"> 677 Ready to deploy? 678 </h2> 679 <p className="text-xl text-muted-foreground mb-8"> 680 Host your static site on your own AT Protocol 681 account today 682 </p> 683 <Button 684 size="lg" 685 className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6" 686 onClick={() => setShowForm(true)} 687 > 688 Get Started 689 <ArrowRight className="ml-2 w-5 h-5" /> 690 </Button> 691 </div> 692 </section> 693 694 {/* Footer */} 695 <footer className="border-t border-border/40 bg-muted/20"> 696 <div className="container mx-auto px-4 py-8"> 697 <div className="text-center text-sm text-muted-foreground"> 698 <p> 699 Built by{' '} 700 <a 701 href="https://bsky.app/profile/nekomimi.pet" 702 target="_blank" 703 rel="noopener noreferrer" 704 className="text-accent hover:text-accent/80 transition-colors font-medium" 705 > 706 @nekomimi.pet 707 </a> 708 {' • '} 709 Contact:{' '} 710 <a 711 href="mailto:contact@wisp.place" 712 className="text-accent hover:text-accent/80 transition-colors font-medium" 713 > 714 contact@wisp.place 715 </a> 716 {' • '} 717 Legal/DMCA:{' '} 718 <a 719 href="mailto:legal@wisp.place" 720 className="text-accent hover:text-accent/80 transition-colors font-medium" 721 > 722 legal@wisp.place 723 </a> 724 </p> 725 <p className="mt-2"> 726 <a 727 href="/acceptable-use" 728 className="text-accent hover:text-accent/80 transition-colors font-medium" 729 > 730 Acceptable Use Policy 731 </a> 732 </p> 733 </div> 734 </div> 735 </footer> 736 </div> 737 </> 738 ) 739} 740 741const root = createRoot(document.getElementById('elysia')!) 742root.render( 743 <AtProtoProvider> 744 <Layout className="gap-6"> 745 <App /> 746 </Layout> 747 </AtProtoProvider> 748)