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