Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { useState, useEffect } from 'react' 2import { createRoot } from 'react-dom/client' 3import { Button } from '@public/components/ui/button' 4import { 5 Card, 6 CardContent, 7 CardDescription, 8 CardHeader, 9 CardTitle 10} from '@public/components/ui/card' 11import { Input } from '@public/components/ui/input' 12import { Label } from '@public/components/ui/label' 13import { Globe, Upload, CheckCircle2, Loader2, AlertCircle } from 'lucide-react' 14import Layout from '@public/layouts' 15 16type OnboardingStep = 'domain' | 'upload' | 'complete' 17 18function Onboarding() { 19 const [step, setStep] = useState<OnboardingStep>('domain') 20 const [handle, setHandle] = useState('') 21 const [isCheckingAvailability, setIsCheckingAvailability] = useState(false) 22 const [isAvailable, setIsAvailable] = useState<boolean | null>(null) 23 const [domain, setDomain] = useState('') 24 const [isClaimingDomain, setIsClaimingDomain] = useState(false) 25 const [claimedDomain, setClaimedDomain] = useState('') 26 27 const [siteName, setSiteName] = useState('') 28 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 29 const [isUploading, setIsUploading] = useState(false) 30 const [uploadProgress, setUploadProgress] = useState('') 31 const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 32 const [uploadedCount, setUploadedCount] = useState(0) 33 34 // Check domain availability as user types 35 useEffect(() => { 36 if (!handle || handle.length < 3) { 37 setIsAvailable(null) 38 setDomain('') 39 return 40 } 41 42 const timeoutId = setTimeout(async () => { 43 setIsCheckingAvailability(true) 44 try { 45 const response = await fetch( 46 `/api/domain/check?handle=${encodeURIComponent(handle)}` 47 ) 48 const data = await response.json() 49 setIsAvailable(data.available) 50 setDomain(data.domain || '') 51 } catch (err) { 52 console.error('Error checking availability:', err) 53 setIsAvailable(false) 54 } finally { 55 setIsCheckingAvailability(false) 56 } 57 }, 500) 58 59 return () => clearTimeout(timeoutId) 60 }, [handle]) 61 62 const handleClaimDomain = async () => { 63 if (!handle || !isAvailable) return 64 65 setIsClaimingDomain(true) 66 try { 67 const response = await fetch('/api/domain/claim', { 68 method: 'POST', 69 headers: { 'Content-Type': 'application/json' }, 70 body: JSON.stringify({ handle }) 71 }) 72 73 const data = await response.json() 74 if (data.success) { 75 setClaimedDomain(data.domain) 76 setStep('upload') 77 } else { 78 alert('Failed to claim domain. Please try again.') 79 } 80 } catch (err) { 81 console.error('Error claiming domain:', err) 82 alert('Failed to claim domain. Please try again.') 83 } finally { 84 setIsClaimingDomain(false) 85 } 86 } 87 88 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 89 if (e.target.files && e.target.files.length > 0) { 90 setSelectedFiles(e.target.files) 91 } 92 } 93 94 const handleUpload = async () => { 95 if (!siteName) { 96 alert('Please enter a site name') 97 return 98 } 99 100 setIsUploading(true) 101 setUploadProgress('Preparing files...') 102 103 try { 104 const formData = new FormData() 105 formData.append('siteName', siteName) 106 107 if (selectedFiles) { 108 for (let i = 0; i < selectedFiles.length; i++) { 109 formData.append('files', selectedFiles[i]) 110 } 111 } 112 113 setUploadProgress('Uploading to AT Protocol...') 114 const response = await fetch('/wisp/upload-files', { 115 method: 'POST', 116 body: formData 117 }) 118 119 const data = await response.json() 120 if (data.success) { 121 setUploadProgress('Upload complete!') 122 setSkippedFiles(data.skippedFiles || []) 123 setUploadedCount(data.uploadedCount || data.fileCount || 0) 124 125 // If there are skipped files, show them briefly before redirecting 126 if (data.skippedFiles && data.skippedFiles.length > 0) { 127 setTimeout(() => { 128 window.location.href = `https://${claimedDomain}` 129 }, 3000) // Give more time to see skipped files 130 } else { 131 setTimeout(() => { 132 window.location.href = `https://${claimedDomain}` 133 }, 1500) 134 } 135 } else { 136 throw new Error(data.error || 'Upload failed') 137 } 138 } catch (err) { 139 console.error('Upload error:', err) 140 alert( 141 `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 142 ) 143 setIsUploading(false) 144 setUploadProgress('') 145 } 146 } 147 148 const handleSkipUpload = () => { 149 // Redirect to editor without uploading 150 window.location.href = '/editor' 151 } 152 153 return ( 154 <div className="w-full min-h-screen bg-background"> 155 {/* Header */} 156 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 157 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 158 <div className="flex items-center gap-2"> 159 <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 160 <Globe className="w-5 h-5 text-primary-foreground" /> 161 </div> 162 <span className="text-xl font-semibold text-foreground"> 163 wisp.place 164 </span> 165 </div> 166 </div> 167 </header> 168 169 <div className="container mx-auto px-4 py-12 max-w-2xl"> 170 {/* Progress indicator */} 171 <div className="mb-8"> 172 <div className="flex items-center justify-center gap-2 mb-4"> 173 <div 174 className={`w-8 h-8 rounded-full flex items-center justify-center ${ 175 step === 'domain' 176 ? 'bg-primary text-primary-foreground' 177 : 'bg-green-500 text-white' 178 }`} 179 > 180 {step === 'domain' ? ( 181 '1' 182 ) : ( 183 <CheckCircle2 className="w-5 h-5" /> 184 )} 185 </div> 186 <div className="w-16 h-0.5 bg-border"></div> 187 <div 188 className={`w-8 h-8 rounded-full flex items-center justify-center ${ 189 step === 'upload' 190 ? 'bg-primary text-primary-foreground' 191 : step === 'domain' 192 ? 'bg-muted text-muted-foreground' 193 : 'bg-green-500 text-white' 194 }`} 195 > 196 {step === 'complete' ? ( 197 <CheckCircle2 className="w-5 h-5" /> 198 ) : ( 199 '2' 200 )} 201 </div> 202 </div> 203 <div className="text-center"> 204 <h1 className="text-2xl font-bold mb-2"> 205 {step === 'domain' && 'Claim Your Free Domain'} 206 {step === 'upload' && 'Deploy Your First Site'} 207 {step === 'complete' && 'All Set!'} 208 </h1> 209 <p className="text-muted-foreground"> 210 {step === 'domain' && 211 'Choose a subdomain on wisp.place'} 212 {step === 'upload' && 213 'Upload your site or start with an empty one'} 214 {step === 'complete' && 'Redirecting to your site...'} 215 </p> 216 </div> 217 </div> 218 219 {/* Domain registration step */} 220 {step === 'domain' && ( 221 <Card> 222 <CardHeader> 223 <CardTitle>Choose Your Domain</CardTitle> 224 <CardDescription> 225 Pick a unique handle for your free *.wisp.place 226 subdomain 227 </CardDescription> 228 </CardHeader> 229 <CardContent className="space-y-4"> 230 <div className="space-y-2"> 231 <Label htmlFor="handle">Your Handle</Label> 232 <div className="flex gap-2"> 233 <div className="relative flex-1"> 234 <Input 235 id="handle" 236 placeholder="my-awesome-site" 237 value={handle} 238 onChange={(e) => 239 setHandle( 240 e.target.value 241 .toLowerCase() 242 .replace(/[^a-z0-9-]/g, '') 243 ) 244 } 245 className="pr-10" 246 /> 247 {isCheckingAvailability && ( 248 <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" /> 249 )} 250 {!isCheckingAvailability && 251 isAvailable !== null && ( 252 <div 253 className={`absolute right-3 top-1/2 -translate-y-1/2 ${ 254 isAvailable 255 ? 'text-green-500' 256 : 'text-red-500' 257 }`} 258 > 259 {isAvailable ? '✓' : '✗'} 260 </div> 261 )} 262 </div> 263 </div> 264 {domain && ( 265 <p className="text-sm text-muted-foreground"> 266 Your domain will be:{' '} 267 <span className="font-mono">{domain}</span> 268 </p> 269 )} 270 {isAvailable === false && handle.length >= 3 && ( 271 <p className="text-sm text-red-500"> 272 This handle is not available or invalid 273 </p> 274 )} 275 </div> 276 277 <Button 278 onClick={handleClaimDomain} 279 disabled={ 280 !isAvailable || 281 isClaimingDomain || 282 isCheckingAvailability 283 } 284 className="w-full" 285 > 286 {isClaimingDomain ? ( 287 <> 288 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 289 Claiming Domain... 290 </> 291 ) : ( 292 <>Claim Domain</> 293 )} 294 </Button> 295 </CardContent> 296 </Card> 297 )} 298 299 {/* Upload step */} 300 {step === 'upload' && ( 301 <Card> 302 <CardHeader> 303 <CardTitle>Deploy Your Site</CardTitle> 304 <CardDescription> 305 Upload your static site files or start with an empty 306 site (you can upload later) 307 </CardDescription> 308 </CardHeader> 309 <CardContent className="space-y-6"> 310 <div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg"> 311 <div className="flex items-center gap-2 text-green-600 dark:text-green-400"> 312 <CheckCircle2 className="w-4 h-4" /> 313 <span className="font-medium"> 314 Domain claimed: {claimedDomain} 315 </span> 316 </div> 317 </div> 318 319 <div className="space-y-2"> 320 <Label htmlFor="site-name">Site Name</Label> 321 <Input 322 id="site-name" 323 placeholder="my-site" 324 value={siteName} 325 onChange={(e) => setSiteName(e.target.value)} 326 /> 327 <p className="text-xs text-muted-foreground"> 328 A unique identifier for this site in your account 329 </p> 330 </div> 331 332 <div className="space-y-2"> 333 <Label>Upload Files (Optional)</Label> 334 <div className="border-2 border-dashed border-border rounded-lg p-8 text-center hover:border-accent transition-colors"> 335 <Upload className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> 336 <input 337 type="file" 338 id="file-upload" 339 multiple 340 onChange={handleFileSelect} 341 className="hidden" 342 {...(({ webkitdirectory: '', directory: '' } as any))} 343 /> 344 <label 345 htmlFor="file-upload" 346 className="cursor-pointer" 347 > 348 <Button 349 variant="outline" 350 type="button" 351 onClick={() => 352 document 353 .getElementById('file-upload') 354 ?.click() 355 } 356 > 357 Choose Folder 358 </Button> 359 </label> 360 {selectedFiles && selectedFiles.length > 0 && ( 361 <p className="text-sm text-muted-foreground mt-3"> 362 {selectedFiles.length} files selected 363 </p> 364 )} 365 </div> 366 <p className="text-xs text-muted-foreground"> 367 Supported: HTML, CSS, JS, images, fonts, and more 368 </p> 369 <p className="text-xs text-muted-foreground"> 370 Limits: 100MB per file, 300MB total 371 </p> 372 </div> 373 374 {uploadProgress && ( 375 <div className="space-y-3"> 376 <div className="p-4 bg-muted rounded-lg"> 377 <div className="flex items-center gap-2"> 378 <Loader2 className="w-4 h-4 animate-spin" /> 379 <span className="text-sm"> 380 {uploadProgress} 381 </span> 382 </div> 383 </div> 384 385 {skippedFiles.length > 0 && ( 386 <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 387 <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 388 <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 389 <div className="flex-1"> 390 <span className="font-medium"> 391 {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 392 </span> 393 {uploadedCount > 0 && ( 394 <span className="text-sm ml-2"> 395 ({uploadedCount} uploaded successfully) 396 </span> 397 )} 398 </div> 399 </div> 400 <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 401 {skippedFiles.slice(0, 5).map((file, idx) => ( 402 <div key={idx} className="text-xs"> 403 <span className="font-mono">{file.name}</span> 404 <span className="text-muted-foreground"> - {file.reason}</span> 405 </div> 406 ))} 407 {skippedFiles.length > 5 && ( 408 <div className="text-xs text-muted-foreground"> 409 ...and {skippedFiles.length - 5} more 410 </div> 411 )} 412 </div> 413 </div> 414 )} 415 </div> 416 )} 417 418 <div className="flex gap-3"> 419 <Button 420 onClick={handleSkipUpload} 421 variant="outline" 422 className="flex-1" 423 disabled={isUploading} 424 > 425 Skip for Now 426 </Button> 427 <Button 428 onClick={handleUpload} 429 className="flex-1" 430 disabled={!siteName || isUploading} 431 > 432 {isUploading ? ( 433 <> 434 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 435 Uploading... 436 </> 437 ) : ( 438 <> 439 {selectedFiles && selectedFiles.length > 0 440 ? 'Upload & Deploy' 441 : 'Create Empty Site'} 442 </> 443 )} 444 </Button> 445 </div> 446 </CardContent> 447 </Card> 448 )} 449 </div> 450 </div> 451 ) 452} 453 454const root = createRoot(document.getElementById('elysia')!) 455root.render( 456 <Layout> 457 <Onboarding /> 458 </Layout> 459)