Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { 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'
12
13import Layout from '@public/layouts'
14import { Button } from '@public/components/ui/button'
15import { Card } from '@public/components/ui/card'
16import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui'
17import 'atproto-ui/styles.css'
18
19const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => {
20 // Fetch once with the hook
21 const { record, rkey, loading } = useLatestRecord<FeedPostRecord>(
22 did,
23 'app.bsky.feed.post'
24 )
25
26 if (loading) return <span>Loading…</span>
27 if (!record || !rkey) return <span>No posts yet.</span>
28
29 // Pass prefetched record—BlueskyPost won't re-fetch it
30 return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} />
31}
32
33function App() {
34 const [showForm, setShowForm] = useState(false)
35 const [checkingAuth, setCheckingAuth] = useState(true)
36 const inputRef = useRef<HTMLInputElement>(null)
37
38 useEffect(() => {
39 // Check authentication status on mount
40 const checkAuth = async () => {
41 try {
42 const response = await fetch('/api/auth/status', {
43 credentials: 'include'
44 })
45 const data = await response.json()
46 if (data.authenticated) {
47 // User is already authenticated, redirect to editor
48 window.location.href = '/editor'
49 return
50 }
51 } catch (error) {
52 console.error('Auth check failed:', error)
53 } finally {
54 setCheckingAuth(false)
55 }
56 }
57
58 checkAuth()
59 }, [])
60
61 useEffect(() => {
62 if (showForm) {
63 setTimeout(() => inputRef.current?.focus(), 500)
64 }
65 }, [showForm])
66
67 if (checkingAuth) {
68 return (
69 <div className="min-h-screen bg-background flex items-center justify-center">
70 <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
71 </div>
72 )
73 }
74
75 return (
76 <>
77 <div className="min-h-screen">
78 {/* Header */}
79 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
80 <div className="container mx-auto px-4 py-4 flex items-center justify-between">
81 <div className="flex items-center gap-2">
82 <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
83 <span className="text-xl font-semibold text-foreground">
84 wisp.place
85 </span>
86 </div>
87 <div className="flex items-center gap-3">
88 <Button
89 variant="ghost"
90 size="sm"
91 onClick={() => setShowForm(true)}
92 >
93 Sign In
94 </Button>
95 <Button
96 size="sm"
97 className="bg-accent text-accent-foreground hover:bg-accent/90"
98 onClick={() => setShowForm(true)}
99 >
100 Get Started
101 </Button>
102 </div>
103 </div>
104 </header>
105
106 {/* Hero Section */}
107 <section className="container mx-auto px-4 py-20 md:py-32">
108 <div className="max-w-4xl mx-auto text-center">
109 <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-8">
110 <span className="w-2 h-2 bg-accent rounded-full animate-pulse"></span>
111 <span className="text-sm text-foreground">
112 Built on AT Protocol
113 </span>
114 </div>
115
116 <h1 className="text-5xl md:text-7xl font-bold text-balance mb-6 leading-tight">
117 Your Website.Your Control. Lightning Fast.
118 </h1>
119
120 <p className="text-xl md:text-2xl text-muted-foreground text-balance mb-10 leading-relaxed max-w-3xl mx-auto">
121 Host static sites in your AT Protocol account. You
122 keep ownership and control. We just serve them fast
123 through our CDN.
124 </p>
125
126 <div className="max-w-md mx-auto relative">
127 <div
128 className={`transition-all duration-500 ease-in-out ${
129 showForm
130 ? 'opacity-0 -translate-y-5 pointer-events-none'
131 : 'opacity-100 translate-y-0'
132 }`}
133 >
134 <Button
135 size="lg"
136 className="bg-primary text-primary-foreground hover:bg-primary/90 text-lg px-8 py-6 w-full"
137 onClick={() => setShowForm(true)}
138 >
139 Log in with AT Proto
140 <ArrowRight className="ml-2 w-5 h-5" />
141 </Button>
142 </div>
143
144 <div
145 className={`transition-all duration-500 ease-in-out absolute inset-0 ${
146 showForm
147 ? 'opacity-100 translate-y-0'
148 : 'opacity-0 translate-y-5 pointer-events-none'
149 }`}
150 >
151 <form
152 onSubmit={async (e) => {
153 e.preventDefault()
154 try {
155 const handle =
156 inputRef.current?.value
157 const res = await fetch(
158 '/api/auth/signin',
159 {
160 method: 'POST',
161 headers: {
162 'Content-Type':
163 'application/json'
164 },
165 body: JSON.stringify({
166 handle
167 })
168 }
169 )
170 if (!res.ok)
171 throw new Error(
172 'Request failed'
173 )
174 const data = await res.json()
175 if (data.url) {
176 window.location.href = data.url
177 } else {
178 alert('Unexpected response')
179 }
180 } catch (error) {
181 console.error(
182 'Login failed:',
183 error
184 )
185 alert('Authentication failed')
186 }
187 }}
188 className="space-y-3"
189 >
190 <input
191 ref={inputRef}
192 type="text"
193 name="handle"
194 placeholder="Enter your handle (e.g., alice.bsky.social)"
195 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"
196 />
197 <button
198 type="submit"
199 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"
200 >
201 Continue
202 <ArrowRight className="ml-2 w-5 h-5" />
203 </button>
204 </form>
205 </div>
206 </div>
207 </div>
208 </section>
209
210 {/* How It Works */}
211 <section className="container mx-auto px-4 py-16 bg-muted/30">
212 <div className="max-w-3xl mx-auto text-center">
213 <h2 className="text-3xl md:text-4xl font-bold mb-8">
214 How it works
215 </h2>
216 <div className="space-y-6 text-left">
217 <div className="flex gap-4 items-start">
218 <div className="text-4xl font-bold text-accent/40 min-w-[60px]">
219 01
220 </div>
221 <div>
222 <h3 className="text-xl font-semibold mb-2">
223 Upload your static site
224 </h3>
225 <p className="text-muted-foreground">
226 Your HTML, CSS, and JavaScript files are
227 stored in your AT Protocol account as
228 gzipped blobs and a manifest record.
229 </p>
230 </div>
231 </div>
232 <div className="flex gap-4 items-start">
233 <div className="text-4xl font-bold text-accent/40 min-w-[60px]">
234 02
235 </div>
236 <div>
237 <h3 className="text-xl font-semibold mb-2">
238 We serve it globally
239 </h3>
240 <p className="text-muted-foreground">
241 Wisp.place reads your site from your
242 account and delivers it through our CDN
243 for fast loading anywhere.
244 </p>
245 </div>
246 </div>
247 <div className="flex gap-4 items-start">
248 <div className="text-4xl font-bold text-accent/40 min-w-[60px]">
249 03
250 </div>
251 <div>
252 <h3 className="text-xl font-semibold mb-2">
253 You stay in control
254 </h3>
255 <p className="text-muted-foreground">
256 Update or remove your site anytime
257 through your AT Protocol account. No
258 lock-in, no middleman ownership.
259 </p>
260 </div>
261 </div>
262 </div>
263 </div>
264 </section>
265
266 {/* Features Grid */}
267 <section id="features" className="container mx-auto px-4 py-20">
268 <div className="text-center mb-16">
269 <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance">
270 Why Wisp.place?
271 </h2>
272 <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto">
273 Static site hosting that respects your ownership
274 </p>
275 </div>
276
277 <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
278 {[
279 {
280 icon: Shield,
281 title: 'You Own Your Content',
282 description:
283 'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.'
284 },
285 {
286 icon: Zap,
287 title: 'CDN Performance',
288 description:
289 'We cache and serve your site from edge locations worldwide for fast load times.'
290 },
291 {
292 icon: Lock,
293 title: 'No Vendor Lock-in',
294 description:
295 'Your data stays in your account. Switch providers or self-host whenever you want.'
296 },
297 {
298 icon: Code,
299 title: 'Simple Deployment',
300 description:
301 'Upload your static files and we handle the rest. No complex configuration needed.'
302 },
303 {
304 icon: Server,
305 title: 'AT Protocol Native',
306 description:
307 'Built for the decentralized web. Your site has a verifiable identity on the network.'
308 },
309 {
310 icon: Globe,
311 title: 'Custom Domains',
312 description:
313 'Use your own domain name or a wisp.place subdomain. Your choice, either way.'
314 }
315 ].map((feature, i) => (
316 <Card
317 key={i}
318 className="p-6 hover:shadow-lg transition-shadow border-2 bg-card"
319 >
320 <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
321 <feature.icon className="w-6 h-6 text-accent" />
322 </div>
323 <h3 className="text-xl font-semibold mb-2 text-card-foreground">
324 {feature.title}
325 </h3>
326 <p className="text-muted-foreground leading-relaxed">
327 {feature.description}
328 </p>
329 </Card>
330 ))}
331 </div>
332 </section>
333
334 {/* CTA Section */}
335 <section className="container mx-auto px-4 py-20">
336 <div className="max-w-6xl mx-auto">
337 <div className="text-center mb-12">
338 <h2 className="text-3xl md:text-4xl font-bold">
339 Follow on Bluesky for updates
340 </h2>
341 </div>
342 <div className="grid md:grid-cols-2 gap-8 items-center">
343 <Card
344 className="shadow-lg border-2 border-border overflow-hidden !py-3"
345 style={{
346 '--atproto-color-bg': 'var(--card)',
347 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)',
348 '--atproto-color-text': 'hsl(var(--foreground))',
349 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))',
350 '--atproto-color-link': 'hsl(var(--accent))',
351 '--atproto-color-link-hover': 'hsl(var(--accent))',
352 '--atproto-color-border': 'transparent',
353 } as AtProtoStyles}
354 >
355 <BlueskyPostList did="wisp.place" />
356 </Card>
357 <div className="space-y-6 w-full max-w-md mx-auto">
358 <Card
359 className="shadow-lg border-2 overflow-hidden relative !py-3"
360 style={{
361 '--atproto-color-bg': 'var(--card)',
362 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)',
363 '--atproto-color-text': 'hsl(var(--foreground))',
364 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))',
365 } as AtProtoStyles}
366 >
367 <BlueskyProfile did="wisp.place" />
368 </Card>
369 <Card
370 className="shadow-lg border-2 overflow-hidden relative !py-3"
371 style={{
372 '--atproto-color-bg': 'var(--card)',
373 '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)',
374 '--atproto-color-text': 'hsl(var(--foreground))',
375 '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))',
376 } as AtProtoStyles}
377 >
378 <LatestPostWithPrefetch did="wisp.place" />
379 </Card>
380 </div>
381 </div>
382 </div>
383 </section>
384
385 {/* Ready to Deploy CTA */}
386 <section className="container mx-auto px-4 py-20">
387 <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12">
388 <h2 className="text-3xl md:text-4xl font-bold mb-4">
389 Ready to deploy?
390 </h2>
391 <p className="text-xl text-muted-foreground mb-8">
392 Host your static site on your own AT Protocol
393 account today
394 </p>
395 <Button
396 size="lg"
397 className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6"
398 onClick={() => setShowForm(true)}
399 >
400 Get Started
401 <ArrowRight className="ml-2 w-5 h-5" />
402 </Button>
403 </div>
404 </section>
405
406 {/* Footer */}
407 <footer className="border-t border-border/40 bg-muted/20">
408 <div className="container mx-auto px-4 py-8">
409 <div className="text-center text-sm text-muted-foreground">
410 <p>
411 Built by{' '}
412 <a
413 href="https://bsky.app/profile/nekomimi.pet"
414 target="_blank"
415 rel="noopener noreferrer"
416 className="text-accent hover:text-accent/80 transition-colors font-medium"
417 >
418 @nekomimi.pet
419 </a>
420 {' • '}
421 Contact:{' '}
422 <a
423 href="mailto:contact@wisp.place"
424 className="text-accent hover:text-accent/80 transition-colors font-medium"
425 >
426 contact@wisp.place
427 </a>
428 {' • '}
429 Legal/DMCA:{' '}
430 <a
431 href="mailto:legal@wisp.place"
432 className="text-accent hover:text-accent/80 transition-colors font-medium"
433 >
434 legal@wisp.place
435 </a>
436 </p>
437 <p className="mt-2">
438 <a
439 href="/acceptable-use"
440 className="text-accent hover:text-accent/80 transition-colors font-medium"
441 >
442 Acceptable Use Policy
443 </a>
444 </p>
445 </div>
446 </div>
447 </footer>
448 </div>
449 </>
450 )
451}
452
453const root = createRoot(document.getElementById('elysia')!)
454root.render(
455 <AtProtoProvider>
456 <Layout className="gap-6">
457 <App />
458 </Layout>
459 </AtProtoProvider>
460)