forked from
nekomimi.pet/wisp.place-monorepo
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 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)