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