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)