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)