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