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">>_</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, caching—all 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)