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