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 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)