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 ? 'color-mix(in oklch, var(--accent) 50%, transparent)' : '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: 'var(--muted)', 250 overflow: 'hidden', 251 flexShrink: 0 252 }} 253 > 254 {actor.avatar && ( 255 <img 256 src={actor.avatar} 257 alt="" 258 loading="lazy" 259 style={{ 260 display: 'block', 261 width: '100%', 262 height: '100%', 263 objectFit: 'cover' 264 }} 265 /> 266 )} 267 </div> 268 <span 269 style={{ 270 whiteSpace: 'nowrap', 271 overflow: 'hidden', 272 textOverflow: 'ellipsis', 273 color: '#000000' 274 }} 275 > 276 {actor.handle} 277 </span> 278 </button> 279 </li> 280 ))} 281 </ul> 282 )} 283 </div> 284 ) 285} 286 287const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => { 288 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>( 289 did, 290 'app.bsky.feed.post' 291 ) 292 293 if (loading) return <span>Loading</span> 294 if (!record || !rkey) return <span>No posts yet.</span> 295 296 return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} /> 297} 298 299function App() { 300 const [showForm, setShowForm] = useState(false) 301 const [checkingAuth, setCheckingAuth] = useState(true) 302 const [screenshots, setScreenshots] = useState<string[]>([]) 303 const inputRef = useRef<HTMLInputElement>(null) 304 305 useEffect(() => { 306 // Check authentication status on mount 307 const checkAuth = async () => { 308 try { 309 const response = await fetch('/api/auth/status', { 310 credentials: 'include' 311 }) 312 const data = await response.json() 313 if (data.authenticated) { 314 // User is already authenticated, redirect to editor 315 window.location.href = '/editor' 316 return 317 } 318 // If not authenticated, clear any stale cookies 319 document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 320 } catch (error) { 321 console.error('Auth check failed:', error) 322 // Clear cookies on error as well 323 document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 324 } finally { 325 setCheckingAuth(false) 326 } 327 } 328 329 checkAuth() 330 }, []) 331 332 useEffect(() => { 333 // Fetch screenshots list 334 const fetchScreenshots = async () => { 335 try { 336 const response = await fetch('/api/screenshots') 337 const data = await response.json() 338 setScreenshots(data.screenshots || []) 339 } catch (error) { 340 console.error('Failed to fetch screenshots:', error) 341 } 342 } 343 344 fetchScreenshots() 345 }, []) 346 347 useEffect(() => { 348 if (showForm) { 349 setTimeout(() => inputRef.current?.focus(), 500) 350 } 351 }, [showForm]) 352 353 if (checkingAuth) { 354 return ( 355 <div className="min-h-screen bg-background flex items-center justify-center"> 356 <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div> 357 </div> 358 ) 359 } 360 361 return ( 362 <> 363 <div className="w-full min-h-screen flex flex-col"> 364 {/* Header */} 365 <header className="w-full border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 366 <div className="max-w-6xl w-full mx-auto px-4 h-16 flex items-center justify-between"> 367 <div className="flex items-center gap-2"> 368 <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 369 <span className="text-lg font-semibold text-foreground"> 370 wisp.place 371 </span> 372 </div> 373 <div className="flex items-center gap-4"> 374 <a 375 href="https://docs.wisp.place" 376 target="_blank" 377 rel="noopener noreferrer" 378 className="text-sm text-muted-foreground hover:text-foreground transition-colors" 379 > 380 Read the Docs 381 </a> 382 <Button 383 variant="outline" 384 size="sm" 385 className="btn-hover-lift" 386 onClick={() => setShowForm(true)} 387 > 388 Sign In 389 </Button> 390 </div> 391 </div> 392 </header> 393 394 {/* Hero Section */} 395 <section className="container mx-auto px-4 py-24 md:py-36"> 396 <div className="max-w-4xl mx-auto text-center"> 397 {/* Main Headline */} 398 <h1 className="animate-fade-in-up animate-delay-100 text-5xl md:text-7xl font-bold mb-2 leading-tight tracking-tight"> 399 Deploy Anywhere. 400 </h1> 401 <h1 className="animate-fade-in-up animate-delay-200 text-5xl md:text-7xl font-bold mb-8 leading-tight tracking-tight text-gradient-animate"> 402 For Free. Forever. 403 </h1> 404 405 {/* Subheadline */} 406 <p className="animate-fade-in-up animate-delay-300 text-lg md:text-xl text-muted-foreground mb-12 leading-relaxed max-w-2xl mx-auto"> 407 The easiest way to deploy and orchestrate static sites. 408 Push updates instantly. Host on our infrastructure or yours. 409 All powered by AT Protocol. 410 </p> 411 412 {/* CTA Buttons */} 413 <div className="animate-fade-in-up animate-delay-400 max-w-lg mx-auto relative"> 414 <div 415 className={`transition-all duration-500 ease-in-out ${showForm 416 ? 'opacity-0 -translate-y-5 pointer-events-none absolute inset-0' 417 : 'opacity-100 translate-y-0' 418 }`} 419 > 420 <div className="flex flex-col sm:flex-row gap-3 justify-center"> 421 <Button 422 size="lg" 423 className="bg-foreground text-background hover:bg-foreground/90 text-base px-6 py-5 btn-hover-lift" 424 onClick={() => setShowForm(true)} 425 > 426 <span className="mr-2 font-bold">@</span> 427 Deploy with AT 428 </Button> 429 <Button 430 variant="outline" 431 size="lg" 432 className="text-base px-6 py-5 btn-hover-lift" 433 asChild 434 > 435 <a href="https://docs.wisp.place/cli/" target="_blank" rel="noopener noreferrer"> 436 <span className="font-mono mr-2 text-muted-foreground">&gt;_</span> 437 Install wisp-cli 438 </a> 439 </Button> 440 </div> 441 </div> 442 443 <div 444 className={`transition-all duration-500 ease-in-out ${showForm 445 ? 'opacity-100 translate-y-0' 446 : 'opacity-0 translate-y-5 pointer-events-none absolute inset-0' 447 }`} 448 > 449 <form 450 onSubmit={async (e) => { 451 e.preventDefault() 452 try { 453 const handle = 454 inputRef.current?.value 455 const res = await fetch( 456 '/api/auth/signin', 457 { 458 method: 'POST', 459 headers: { 460 'Content-Type': 461 'application/json' 462 }, 463 body: JSON.stringify({ 464 handle 465 }) 466 } 467 ) 468 if (!res.ok) 469 throw new Error( 470 'Request failed' 471 ) 472 const data = await res.json() 473 if (data.url) { 474 window.location.href = data.url 475 } else { 476 alert('Unexpected response') 477 } 478 } catch (error) { 479 console.error( 480 'Login failed:', 481 error 482 ) 483 // Clear any invalid cookies 484 document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 485 alert('Authentication failed') 486 } 487 }} 488 className="space-y-3" 489 > 490 <ActorTypeahead 491 autoSubmit={true} 492 onSelect={(handle) => { 493 if (inputRef.current) { 494 inputRef.current.value = handle 495 } 496 }} 497 > 498 <input 499 ref={inputRef} 500 type="text" 501 name="handle" 502 placeholder="Enter your handle (e.g., alice.bsky.social)" 503 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" 504 /> 505 </ActorTypeahead> 506 <button 507 type="submit" 508 className="w-full bg-foreground text-background hover:bg-foreground/90 font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors btn-hover-lift" 509 > 510 Continue 511 <ArrowRight className="ml-2 w-5 h-5" /> 512 </button> 513 </form> 514 </div> 515 </div> 516 </div> 517 </section> 518 519 {/* How It Works */} 520 <section className="container mx-auto px-4 py-16 bg-muted/30"> 521 <div className="max-w-3xl mx-auto text-center"> 522 <h2 className="text-3xl md:text-4xl font-bold mb-8"> 523 How it works 524 </h2> 525 <div className="space-y-6 text-left"> 526 <div className="flex gap-4 items-start"> 527 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 528 01 529 </div> 530 <div> 531 <h3 className="text-xl font-semibold mb-2"> 532 Drop in your files 533 </h3> 534 <p className="text-muted-foreground"> 535 Upload your site through our dashboard or push with the CLI. 536 Everything gets stored directly in your AT Protocol account. 537 </p> 538 </div> 539 </div> 540 <div className="flex gap-4 items-start"> 541 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 542 02 543 </div> 544 <div> 545 <h3 className="text-xl font-semibold mb-2"> 546 We handle the rest 547 </h3> 548 <p className="text-muted-foreground"> 549 Your site goes live instantly on our global CDN. 550 Custom domains, HTTPS, cachingall automatic. 551 </p> 552 </div> 553 </div> 554 <div className="flex gap-4 items-start"> 555 <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 556 03 557 </div> 558 <div> 559 <h3 className="text-xl font-semibold mb-2"> 560 Push updates instantly 561 </h3> 562 <p className="text-muted-foreground"> 563 Ship changes in seconds. Update through the dashboard, 564 run wisp-cli deploy, or wire up your CI/CD pipeline. 565 </p> 566 </div> 567 </div> 568 </div> 569 </div> 570 </section> 571 572 {/* Site Gallery */} 573 <section id="gallery" className="container mx-auto px-4 py-20"> 574 <div className="text-center mb-16"> 575 <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 576 Join 80+ sites just like yours: 577 </h2> 578 </div> 579 580 <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-5xl mx-auto"> 581 {screenshots.map((filename, i) => { 582 // Remove .png extension 583 const baseName = filename.replace('.png', '') 584 585 // Construct site URL from filename 586 let siteUrl: string 587 if (baseName.startsWith('sites_wisp_place_did_plc_')) { 588 // Handle format: sites_wisp_place_did_plc_{identifier}_{sitename} 589 const match = baseName.match(/^sites_wisp_place_did_plc_([a-z0-9]+)_(.+)$/) 590 if (match) { 591 const [, identifier, sitename] = match 592 siteUrl = `https://sites.wisp.place/did:plc:${identifier}/${sitename}` 593 } else { 594 siteUrl = '#' 595 } 596 } else { 597 // Handle format: domain_tld or subdomain_domain_tld 598 // Replace underscores with dots 599 siteUrl = `https://${baseName.replace(/_/g, '.')}` 600 } 601 602 return ( 603 <a 604 key={i} 605 href={siteUrl} 606 target="_blank" 607 rel="noopener noreferrer" 608 className="block" 609 > 610 <Card className="overflow-hidden hover:shadow-xl transition-all hover:scale-105 border-2 bg-card p-0 cursor-pointer"> 611 <img 612 src={`/screenshots/${filename}`} 613 alt={`${baseName} screenshot`} 614 className="w-full h-auto object-cover aspect-video" 615 loading="lazy" 616 /> 617 </Card> 618 </a> 619 ) 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 mt-auto"> 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)