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 asChild 376 > 377 <a href="https://docs.wisp.place" target="_blank" rel="noopener noreferrer"> 378 Read the Docs 379 </a> 380 </Button> 381 </div> 382 </div> 383 </header> 384 385 {/* Hero Section */} 386 <section className="container mx-auto px-4 py-20 md:py-32"> 387 <div className="max-w-4xl mx-auto text-center"> 388 <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8"> 389 <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span> 390 <span className="text-sm text-foreground"> 391 Built on AT Protocol 392 </span> 393 </div> 394 395 <h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight"> 396 Your Website.Your Control. Lightning Fast. 397 </h1> 398 399 <p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto"> 400 Host static sites in your AT Protocol account. You 401 keep ownership and control. We just serve them fast 402 through our CDN. 403 </p> 404 405 <div className="max-w-md mx-auto relative"> 406 <div 407 className={`transition-all duration-500 ease-in-out ${ 408 showForm 409 ? 'opacity-0 -translate-y-5 pointer-events-none' 410 : 'opacity-100 translate-y-0' 411 }`} 412 > 413 <Button 414 size="lg" 415 className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full" 416 onClick={() => setShowForm(true)} 417 > 418 Log in with AT Proto 419 <ArrowRight className="ml-2 w-5 h-5" /> 420 </Button> 421 </div> 422 423 <div 424 className={`transition-all duration-500 ease-in-out absolute inset-0 ${ 425 showForm 426 ? 'opacity-100 translate-y-0' 427 : 'opacity-0 translate-y-5 pointer-events-none' 428 }`} 429 > 430 <form 431 onSubmit={async (e) => { 432 e.preventDefault() 433 try { 434 const handle = 435 inputRef.current?.value 436 const res = await fetch( 437 '/api/auth/signin', 438 { 439 method: 'POST', 440 headers: { 441 'Content-Type': 442 'application/json' 443 }, 444 body: JSON.stringify({ 445 handle 446 }) 447 } 448 ) 449 if (!res.ok) 450 throw new Error( 451 'Request failed' 452 ) 453 const data = await res.json() 454 if (data.url) { 455 window.location.href = data.url 456 } else { 457 alert('Unexpected response') 458 } 459 } catch (error) { 460 console.error( 461 'Login failed:', 462 error 463 ) 464 // Clear any invalid cookies 465 document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 466 alert('Authentication failed') 467 } 468 }} 469 className="space-y-3" 470 > 471 <ActorTypeahead 472 autoSubmit={true} 473 onSelect={(handle) => { 474 if (inputRef.current) { 475 inputRef.current.value = handle 476 } 477 }} 478 > 479 <input 480 ref={inputRef} 481 type="text" 482 name="handle" 483 placeholder="Enter your handle (e.g., alice.bsky.social)" 484 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" 485 /> 486 </ActorTypeahead> 487 <button 488 type="submit" 489 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" 490 > 491 Continue 492 <ArrowRight className="ml-2 w-5 h-5" /> 493 </button> 494 </form> 495 </div> 496 </div> 497 </div> 498 </section> 499 500 {/* How It Works */} 501 <section className="container mx-auto px-4 py-16 bg-muted/30"> 502 <div className="max-w-3xl mx-auto text-center"> 503 <h2 className="text-3xl md:text-4xl font-bold mb-8"> 504 How it works 505 </h2> 506 <div className="space-y-6 text-left"> 507 <div className="flex gap-4 items-start"> 508 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 509 01 510 </div> 511 <div> 512 <h3 className="text-xl font-semibold mb-2"> 513 Upload your static site 514 </h3> 515 <p className="text-muted-foreground"> 516 Your HTML, CSS, and JavaScript files are 517 stored in your AT Protocol account as 518 gzipped blobs and a manifest record. 519 </p> 520 </div> 521 </div> 522 <div className="flex gap-4 items-start"> 523 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 524 02 525 </div> 526 <div> 527 <h3 className="text-xl font-semibold mb-2"> 528 We serve it globally 529 </h3> 530 <p className="text-muted-foreground"> 531 Wisp.place reads your site from your 532 account and delivers it through our CDN 533 for fast loading anywhere. 534 </p> 535 </div> 536 </div> 537 <div className="flex gap-4 items-start"> 538 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 539 03 540 </div> 541 <div> 542 <h3 className="text-xl font-semibold mb-2"> 543 You stay in control 544 </h3> 545 <p className="text-muted-foreground"> 546 Update or remove your site anytime 547 through your AT Protocol account. No 548 lock-in, no middleman ownership. 549 </p> 550 </div> 551 </div> 552 </div> 553 </div> 554 </section> 555 556 {/* Features Grid */} 557 <section id="features" className="container mx-auto px-4 py-20"> 558 <div className="text-center mb-16"> 559 <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 560 Why Wisp.place? 561 </h2> 562 <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto"> 563 Static site hosting that respects your ownership 564 </p> 565 </div> 566 567 <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> 568 {[ 569 { 570 icon: Shield, 571 title: 'You Own Your Content', 572 description: 573 'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.' 574 }, 575 { 576 icon: Zap, 577 title: 'CDN Performance', 578 description: 579 'We cache and serve your site from edge locations worldwide for fast load times.' 580 }, 581 { 582 icon: Lock, 583 title: 'No Vendor Lock-in', 584 description: 585 'Your data stays in your account. Switch providers or self-host whenever you want.' 586 }, 587 { 588 icon: Code, 589 title: 'Simple Deployment', 590 description: 591 'Upload your static files and we handle the rest. No complex configuration needed.' 592 }, 593 { 594 icon: Server, 595 title: 'AT Protocol Native', 596 description: 597 'Built for the decentralized web. Your site has a verifiable identity on the network.' 598 }, 599 { 600 icon: Globe, 601 title: 'Custom Domains', 602 description: 603 'Use your own domain name or a wisp.place subdomain. Your choice, either way.' 604 } 605 ].map((feature, i) => ( 606 <Card 607 key={i} 608 className="p-6 hover:shadow-lg transition-shadow border-2 bg-card" 609 > 610 <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4"> 611 <feature.icon className="w-6 h-6 text-accent" /> 612 </div> 613 <h3 className="text-xl font-semibold mb-2 text-card-foreground"> 614 {feature.title} 615 </h3> 616 <p className="text-muted-foreground leading-relaxed"> 617 {feature.description} 618 </p> 619 </Card> 620 ))} 621 </div> 622 </section> 623 624 {/* CTA Section */} 625 <section className="container mx-auto px-4 py-20"> 626 <div className="max-w-6xl mx-auto"> 627 <div className="text-center mb-12"> 628 <h2 className="text-3xl md:text-4xl font-bold"> 629 Follow on Bluesky for updates 630 </h2> 631 </div> 632 <div className="grid md:grid-cols-2 gap-8 items-center"> 633 <Card 634 className="shadow-lg border-2 border-border overflow-hidden !py-3" 635 style={{ 636 '--atproto-color-bg': 'var(--card)', 637 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 638 '--atproto-color-text': 'hsl(var(--foreground))', 639 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 640 '--atproto-color-link': 'hsl(var(--accent))', 641 '--atproto-color-link-hover': 'hsl(var(--accent))', 642 '--atproto-color-border': 'transparent', 643 } as AtProtoStyles} 644 > 645 <BlueskyPostList did="wisp.place" /> 646 </Card> 647 <div className="space-y-6 w-full max-w-md mx-auto"> 648 <Card 649 className="shadow-lg border-2 overflow-hidden relative !py-3" 650 style={{ 651 '--atproto-color-bg': 'var(--card)', 652 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 653 '--atproto-color-text': 'hsl(var(--foreground))', 654 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 655 } as AtProtoStyles} 656 > 657 <BlueskyProfile did="wisp.place" /> 658 </Card> 659 <Card 660 className="shadow-lg border-2 overflow-hidden relative !py-3" 661 style={{ 662 '--atproto-color-bg': 'var(--card)', 663 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 664 '--atproto-color-text': 'hsl(var(--foreground))', 665 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 666 } as AtProtoStyles} 667 > 668 <LatestPostWithPrefetch did="wisp.place" /> 669 </Card> 670 </div> 671 </div> 672 </div> 673 </section> 674 675 {/* Ready to Deploy CTA */} 676 <section className="container mx-auto px-4 py-20"> 677 <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 678 <h2 className="text-3xl md:text-4xl font-bold mb-4"> 679 Ready to deploy? 680 </h2> 681 <p className="text-xl text-muted-foreground mb-8"> 682 Host your static site on your own AT Protocol 683 account today 684 </p> 685 <Button 686 size="lg" 687 className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6" 688 onClick={() => setShowForm(true)} 689 > 690 Get Started 691 <ArrowRight className="ml-2 w-5 h-5" /> 692 </Button> 693 </div> 694 </section> 695 696 {/* Footer */} 697 <footer className="border-t border-border/40 bg-muted/20"> 698 <div className="container mx-auto px-4 py-8"> 699 <div className="text-center text-sm text-muted-foreground"> 700 <p> 701 Built by{' '} 702 <a 703 href="https://bsky.app/profile/nekomimi.pet" 704 target="_blank" 705 rel="noopener noreferrer" 706 className="text-accent hover:text-accent/80 transition-colors font-medium" 707 > 708 @nekomimi.pet 709 </a> 710 {' • '} 711 Contact:{' '} 712 <a 713 href="mailto:contact@wisp.place" 714 className="text-accent hover:text-accent/80 transition-colors font-medium" 715 > 716 contact@wisp.place 717 </a> 718 {' • '} 719 Legal/DMCA:{' '} 720 <a 721 href="mailto:legal@wisp.place" 722 className="text-accent hover:text-accent/80 transition-colors font-medium" 723 > 724 legal@wisp.place 725 </a> 726 </p> 727 <p className="mt-2"> 728 <a 729 href="/acceptable-use" 730 className="text-accent hover:text-accent/80 transition-colors font-medium" 731 > 732 Acceptable Use Policy 733 </a> 734 {' • '} 735 <a 736 href="https://docs.wisp.place" 737 target="_blank" 738 rel="noopener noreferrer" 739 className="text-accent hover:text-accent/80 transition-colors font-medium" 740 > 741 Documentation 742 </a> 743 </p> 744 </div> 745 </div> 746 </footer> 747 </div> 748 </> 749 ) 750} 751 752const root = createRoot(document.getElementById('elysia')!) 753root.render( 754 <AtProtoProvider> 755 <Layout className="gap-6"> 756 <App /> 757 </Layout> 758 </AtProtoProvider> 759)