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)