tangled
alpha
login
or
join now
notjack.space
/
wisp.place-monorepo
forked from
nekomimi.pet/wisp.place-monorepo
0
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
0
fork
atom
overview
issues
pulls
pipelines
init work
nekomimi.pet
4 months ago
830fcffc
56978aa7
+1956
-1423
10 changed files
expand all
collapse all
unified
split
public
editor
components
TabSkeleton.tsx
editor.tsx
hooks
useDomainData.ts
useSiteData.ts
useUserInfo.ts
index.html
tabs
CLITab.tsx
DomainsTab.tsx
SitesTab.tsx
UploadTab.tsx
+65
public/editor/components/TabSkeleton.tsx
···
1
1
+
import {
2
2
+
Card,
3
3
+
CardContent,
4
4
+
CardDescription,
5
5
+
CardHeader,
6
6
+
CardTitle
7
7
+
} from '@public/components/ui/card'
8
8
+
9
9
+
// Shimmer animation for skeleton loading
10
10
+
const Shimmer = () => (
11
11
+
<div className="animate-pulse">
12
12
+
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
13
13
+
<div className="h-4 bg-muted rounded w-1/2"></div>
14
14
+
</div>
15
15
+
)
16
16
+
17
17
+
const SkeletonLine = ({ className = '' }: { className?: string }) => (
18
18
+
<div className={`animate-pulse bg-muted rounded ${className}`}></div>
19
19
+
)
20
20
+
21
21
+
export function TabSkeleton() {
22
22
+
return (
23
23
+
<div className="space-y-4 min-h-[400px]">
24
24
+
<Card>
25
25
+
<CardHeader>
26
26
+
<div className="space-y-2">
27
27
+
<SkeletonLine className="h-6 w-1/3" />
28
28
+
<SkeletonLine className="h-4 w-2/3" />
29
29
+
</div>
30
30
+
</CardHeader>
31
31
+
<CardContent className="space-y-4">
32
32
+
{/* Skeleton content items */}
33
33
+
<div className="p-4 border border-border rounded-lg">
34
34
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
35
35
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
36
36
+
<SkeletonLine className="h-4 w-2/3" />
37
37
+
</div>
38
38
+
<div className="p-4 border border-border rounded-lg">
39
39
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
40
40
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
41
41
+
<SkeletonLine className="h-4 w-2/3" />
42
42
+
</div>
43
43
+
<div className="p-4 border border-border rounded-lg">
44
44
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
45
45
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
46
46
+
<SkeletonLine className="h-4 w-2/3" />
47
47
+
</div>
48
48
+
</CardContent>
49
49
+
</Card>
50
50
+
51
51
+
<Card>
52
52
+
<CardHeader>
53
53
+
<div className="space-y-2">
54
54
+
<SkeletonLine className="h-6 w-1/4" />
55
55
+
<SkeletonLine className="h-4 w-1/2" />
56
56
+
</div>
57
57
+
</CardHeader>
58
58
+
<CardContent className="space-y-3">
59
59
+
<SkeletonLine className="h-10 w-full" />
60
60
+
<SkeletonLine className="h-4 w-3/4" />
61
61
+
</CardContent>
62
62
+
</Card>
63
63
+
</div>
64
64
+
)
65
65
+
}
+73
-1423
public/editor/editor.tsx
···
2
2
import { createRoot } from 'react-dom/client'
3
3
import { Button } from '@public/components/ui/button'
4
4
import {
5
5
-
Card,
6
6
-
CardContent,
7
7
-
CardDescription,
8
8
-
CardHeader,
9
9
-
CardTitle
10
10
-
} from '@public/components/ui/card'
11
11
-
import { Input } from '@public/components/ui/input'
12
12
-
import { Label } from '@public/components/ui/label'
13
13
-
import {
14
5
Tabs,
15
6
TabsContent,
16
7
TabsList,
17
8
TabsTrigger
18
9
} from '@public/components/ui/tabs'
19
19
-
import { Badge } from '@public/components/ui/badge'
20
10
import {
21
11
Dialog,
22
12
DialogContent,
···
25
15
DialogTitle,
26
16
DialogFooter
27
17
} from '@public/components/ui/dialog'
18
18
+
import { Checkbox } from '@public/components/ui/checkbox'
19
19
+
import { Label } from '@public/components/ui/label'
20
20
+
import { Badge } from '@public/components/ui/badge'
28
21
import {
29
22
Globe,
30
30
-
Upload,
31
31
-
ExternalLink,
32
32
-
CheckCircle2,
33
33
-
XCircle,
34
34
-
AlertCircle,
35
23
Loader2,
36
36
-
Trash2,
37
37
-
RefreshCw,
38
38
-
Settings
24
24
+
Trash2
39
25
} from 'lucide-react'
40
40
-
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
41
-
import { Checkbox } from '@public/components/ui/checkbox'
42
42
-
import { CodeBlock } from '@public/components/ui/code-block'
43
43
-
44
26
import Layout from '@public/layouts'
45
45
-
46
46
-
interface UserInfo {
47
47
-
did: string
48
48
-
handle: string
49
49
-
}
50
50
-
51
51
-
interface Site {
52
52
-
did: string
53
53
-
rkey: string
54
54
-
display_name: string | null
55
55
-
created_at: number
56
56
-
updated_at: number
57
57
-
}
58
58
-
59
59
-
interface DomainInfo {
60
60
-
type: 'wisp' | 'custom'
61
61
-
domain: string
62
62
-
verified?: boolean
63
63
-
id?: string
64
64
-
}
65
65
-
66
66
-
interface SiteWithDomains extends Site {
67
67
-
domains?: DomainInfo[]
68
68
-
}
69
69
-
70
70
-
interface CustomDomain {
71
71
-
id: string
72
72
-
domain: string
73
73
-
did: string
74
74
-
rkey: string
75
75
-
verified: boolean
76
76
-
last_verified_at: number | null
77
77
-
created_at: number
78
78
-
}
79
79
-
80
80
-
interface WispDomain {
81
81
-
domain: string
82
82
-
rkey: string | null
83
83
-
}
27
27
+
import { useUserInfo } from './hooks/useUserInfo'
28
28
+
import { useSiteData, type SiteWithDomains } from './hooks/useSiteData'
29
29
+
import { useDomainData } from './hooks/useDomainData'
30
30
+
import { SitesTab } from './tabs/SitesTab'
31
31
+
import { DomainsTab } from './tabs/DomainsTab'
32
32
+
import { UploadTab } from './tabs/UploadTab'
33
33
+
import { CLITab } from './tabs/CLITab'
84
34
85
35
function Dashboard() {
86
86
-
// User state
87
87
-
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
88
88
-
const [loading, setLoading] = useState(true)
36
36
+
// Use custom hooks
37
37
+
const { userInfo, loading, fetchUserInfo } = useUserInfo()
38
38
+
const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData()
39
39
+
const {
40
40
+
wispDomain,
41
41
+
customDomains,
42
42
+
domainsLoading,
43
43
+
verificationStatus,
44
44
+
fetchDomains,
45
45
+
addCustomDomain,
46
46
+
verifyDomain,
47
47
+
deleteCustomDomain,
48
48
+
mapWispDomain,
49
49
+
mapCustomDomain,
50
50
+
claimWispDomain,
51
51
+
checkWispAvailability
52
52
+
} = useDomainData()
89
53
90
90
-
// Sites state
91
91
-
const [sites, setSites] = useState<SiteWithDomains[]>([])
92
92
-
const [sitesLoading, setSitesLoading] = useState(true)
93
93
-
const [isSyncing, setIsSyncing] = useState(false)
94
94
-
95
95
-
// Domains state
96
96
-
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
97
97
-
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
98
98
-
const [domainsLoading, setDomainsLoading] = useState(true)
99
99
-
100
100
-
// Site configuration state
54
54
+
// Site configuration modal state (shared across components)
101
55
const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
102
56
const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
103
57
const [isSavingConfig, setIsSavingConfig] = useState(false)
104
58
const [isDeletingSite, setIsDeletingSite] = useState(false)
105
59
106
106
-
// Upload state
107
107
-
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
108
108
-
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
109
109
-
const [newSiteName, setNewSiteName] = useState('')
110
110
-
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
111
111
-
const [isUploading, setIsUploading] = useState(false)
112
112
-
const [uploadProgress, setUploadProgress] = useState('')
113
113
-
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
114
114
-
const [uploadedCount, setUploadedCount] = useState(0)
115
115
-
116
116
-
// Custom domain modal state
117
117
-
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
118
118
-
const [customDomain, setCustomDomain] = useState('')
119
119
-
const [isAddingDomain, setIsAddingDomain] = useState(false)
120
120
-
const [verificationStatus, setVerificationStatus] = useState<{
121
121
-
[id: string]: 'idle' | 'verifying' | 'success' | 'error'
122
122
-
}>({})
123
123
-
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
124
124
-
125
125
-
// Wisp domain claim state
126
126
-
const [wispHandle, setWispHandle] = useState('')
127
127
-
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
128
128
-
const [wispAvailability, setWispAvailability] = useState<{
129
129
-
available: boolean | null
130
130
-
checking: boolean
131
131
-
}>({ available: null, checking: false })
132
132
-
133
133
-
// Fetch user info on mount
60
60
+
// Fetch initial data on mount
134
61
useEffect(() => {
135
62
fetchUserInfo()
136
63
fetchSites()
137
64
fetchDomains()
138
65
}, [])
139
66
140
140
-
// Auto-switch to 'new' mode if no sites exist
141
141
-
useEffect(() => {
142
142
-
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
143
143
-
setSiteMode('new')
144
144
-
}
145
145
-
}, [sites, sitesLoading, siteMode])
146
146
-
147
147
-
const fetchUserInfo = async () => {
148
148
-
try {
149
149
-
const response = await fetch('/api/user/info')
150
150
-
const data = await response.json()
151
151
-
setUserInfo(data)
152
152
-
} catch (err) {
153
153
-
console.error('Failed to fetch user info:', err)
154
154
-
} finally {
155
155
-
setLoading(false)
156
156
-
}
157
157
-
}
158
158
-
159
159
-
const fetchSites = async () => {
160
160
-
try {
161
161
-
const response = await fetch('/api/user/sites')
162
162
-
const data = await response.json()
163
163
-
const sitesData: Site[] = data.sites || []
164
164
-
165
165
-
// Fetch domain info for each site
166
166
-
const sitesWithDomains = await Promise.all(
167
167
-
sitesData.map(async (site) => {
168
168
-
try {
169
169
-
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
170
170
-
const domainsData = await domainsResponse.json()
171
171
-
return {
172
172
-
...site,
173
173
-
domains: domainsData.domains || []
174
174
-
}
175
175
-
} catch (err) {
176
176
-
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
177
177
-
return {
178
178
-
...site,
179
179
-
domains: []
180
180
-
}
181
181
-
}
182
182
-
})
183
183
-
)
184
184
-
185
185
-
setSites(sitesWithDomains)
186
186
-
} catch (err) {
187
187
-
console.error('Failed to fetch sites:', err)
188
188
-
} finally {
189
189
-
setSitesLoading(false)
190
190
-
}
191
191
-
}
192
192
-
193
193
-
const syncSites = async () => {
194
194
-
setIsSyncing(true)
195
195
-
try {
196
196
-
const response = await fetch('/api/user/sync', {
197
197
-
method: 'POST'
198
198
-
})
199
199
-
const data = await response.json()
200
200
-
if (data.success) {
201
201
-
console.log(`Synced ${data.synced} sites from PDS`)
202
202
-
// Refresh sites list
203
203
-
await fetchSites()
204
204
-
}
205
205
-
} catch (err) {
206
206
-
console.error('Failed to sync sites:', err)
207
207
-
alert('Failed to sync sites from PDS')
208
208
-
} finally {
209
209
-
setIsSyncing(false)
210
210
-
}
211
211
-
}
212
212
-
213
213
-
const fetchDomains = async () => {
214
214
-
try {
215
215
-
const response = await fetch('/api/user/domains')
216
216
-
const data = await response.json()
217
217
-
setWispDomain(data.wispDomain)
218
218
-
setCustomDomains(data.customDomains || [])
219
219
-
} catch (err) {
220
220
-
console.error('Failed to fetch domains:', err)
221
221
-
} finally {
222
222
-
setDomainsLoading(false)
223
223
-
}
224
224
-
}
225
225
-
226
226
-
const getSiteUrl = (site: SiteWithDomains) => {
227
227
-
// Use the first mapped domain if available
228
228
-
if (site.domains && site.domains.length > 0) {
229
229
-
return `https://${site.domains[0].domain}`
230
230
-
}
231
231
-
232
232
-
// Default fallback URL - use handle instead of DID
233
233
-
if (!userInfo) return '#'
234
234
-
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
235
235
-
}
236
236
-
237
237
-
const getSiteDomainName = (site: SiteWithDomains) => {
238
238
-
// Return the first domain if available
239
239
-
if (site.domains && site.domains.length > 0) {
240
240
-
return site.domains[0].domain
241
241
-
}
242
242
-
243
243
-
// Use handle instead of DID for display
244
244
-
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
245
245
-
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
246
246
-
}
247
247
-
248
248
-
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
249
249
-
if (e.target.files && e.target.files.length > 0) {
250
250
-
setSelectedFiles(e.target.files)
251
251
-
}
252
252
-
}
253
253
-
254
254
-
const handleUpload = async () => {
255
255
-
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
256
256
-
257
257
-
if (!siteName) {
258
258
-
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
259
259
-
return
260
260
-
}
261
261
-
262
262
-
setIsUploading(true)
263
263
-
setUploadProgress('Preparing files...')
264
264
-
265
265
-
try {
266
266
-
const formData = new FormData()
267
267
-
formData.append('siteName', siteName)
268
268
-
269
269
-
if (selectedFiles) {
270
270
-
for (let i = 0; i < selectedFiles.length; i++) {
271
271
-
formData.append('files', selectedFiles[i])
272
272
-
}
273
273
-
}
274
274
-
275
275
-
setUploadProgress('Uploading to AT Protocol...')
276
276
-
const response = await fetch('/wisp/upload-files', {
277
277
-
method: 'POST',
278
278
-
body: formData
279
279
-
})
280
280
-
281
281
-
const data = await response.json()
282
282
-
if (data.success) {
283
283
-
setUploadProgress('Upload complete!')
284
284
-
setSkippedFiles(data.skippedFiles || [])
285
285
-
setUploadedCount(data.uploadedCount || data.fileCount || 0)
286
286
-
setSelectedSiteRkey('')
287
287
-
setNewSiteName('')
288
288
-
setSelectedFiles(null)
289
289
-
290
290
-
// Refresh sites list
291
291
-
await fetchSites()
292
292
-
293
293
-
// Reset form - give more time if there are skipped files
294
294
-
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
295
295
-
setTimeout(() => {
296
296
-
setUploadProgress('')
297
297
-
setSkippedFiles([])
298
298
-
setUploadedCount(0)
299
299
-
setIsUploading(false)
300
300
-
}, resetDelay)
301
301
-
} else {
302
302
-
throw new Error(data.error || 'Upload failed')
303
303
-
}
304
304
-
} catch (err) {
305
305
-
console.error('Upload error:', err)
306
306
-
alert(
307
307
-
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
308
308
-
)
309
309
-
setIsUploading(false)
310
310
-
setUploadProgress('')
311
311
-
}
312
312
-
}
313
313
-
314
314
-
const handleAddCustomDomain = async () => {
315
315
-
if (!customDomain) {
316
316
-
alert('Please enter a domain')
317
317
-
return
318
318
-
}
319
319
-
320
320
-
setIsAddingDomain(true)
321
321
-
try {
322
322
-
const response = await fetch('/api/domain/custom/add', {
323
323
-
method: 'POST',
324
324
-
headers: { 'Content-Type': 'application/json' },
325
325
-
body: JSON.stringify({ domain: customDomain })
326
326
-
})
327
327
-
328
328
-
const data = await response.json()
329
329
-
if (data.success) {
330
330
-
setCustomDomain('')
331
331
-
setAddDomainModalOpen(false)
332
332
-
await fetchDomains()
333
333
-
334
334
-
// Automatically show DNS configuration for the newly added domain
335
335
-
setViewDomainDNS(data.id)
336
336
-
} else {
337
337
-
throw new Error(data.error || 'Failed to add domain')
338
338
-
}
339
339
-
} catch (err) {
340
340
-
console.error('Add domain error:', err)
341
341
-
alert(
342
342
-
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
343
343
-
)
344
344
-
} finally {
345
345
-
setIsAddingDomain(false)
346
346
-
}
347
347
-
}
348
348
-
349
349
-
const handleVerifyDomain = async (id: string) => {
350
350
-
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
351
351
-
352
352
-
try {
353
353
-
const response = await fetch('/api/domain/custom/verify', {
354
354
-
method: 'POST',
355
355
-
headers: { 'Content-Type': 'application/json' },
356
356
-
body: JSON.stringify({ id })
357
357
-
})
358
358
-
359
359
-
const data = await response.json()
360
360
-
if (data.success && data.verified) {
361
361
-
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
362
362
-
await fetchDomains()
363
363
-
} else {
364
364
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
365
365
-
if (data.error) {
366
366
-
alert(`Verification failed: ${data.error}`)
367
367
-
}
368
368
-
}
369
369
-
} catch (err) {
370
370
-
console.error('Verify domain error:', err)
371
371
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
372
372
-
alert(
373
373
-
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
374
374
-
)
375
375
-
}
376
376
-
}
377
377
-
378
378
-
const handleDeleteCustomDomain = async (id: string) => {
379
379
-
if (!confirm('Are you sure you want to remove this custom domain?')) {
380
380
-
return
381
381
-
}
382
382
-
383
383
-
try {
384
384
-
const response = await fetch(`/api/domain/custom/${id}`, {
385
385
-
method: 'DELETE'
386
386
-
})
387
387
-
388
388
-
const data = await response.json()
389
389
-
if (data.success) {
390
390
-
await fetchDomains()
391
391
-
} else {
392
392
-
throw new Error('Failed to delete domain')
393
393
-
}
394
394
-
} catch (err) {
395
395
-
console.error('Delete domain error:', err)
396
396
-
alert(
397
397
-
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
398
398
-
)
399
399
-
}
400
400
-
}
401
401
-
67
67
+
// Handle site configuration modal
402
68
const handleConfigureSite = (site: SiteWithDomains) => {
403
69
setConfiguringSite(site)
404
70
···
429
95
430
96
// Handle wisp domain mapping
431
97
if (shouldMapWisp && !isCurrentlyMappedToWisp) {
432
432
-
// Map to wisp domain
433
433
-
const response = await fetch('/api/domain/wisp/map-site', {
434
434
-
method: 'POST',
435
435
-
headers: { 'Content-Type': 'application/json' },
436
436
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
437
437
-
})
438
438
-
const data = await response.json()
439
439
-
if (!data.success) throw new Error('Failed to map wisp domain')
98
98
+
await mapWispDomain(configuringSite.rkey)
440
99
} else if (!shouldMapWisp && isCurrentlyMappedToWisp) {
441
441
-
// Unmap from wisp domain
442
442
-
await fetch('/api/domain/wisp/map-site', {
443
443
-
method: 'POST',
444
444
-
headers: { 'Content-Type': 'application/json' },
445
445
-
body: JSON.stringify({ siteRkey: null })
446
446
-
})
100
100
+
await mapWispDomain(null)
447
101
}
448
102
449
103
// Handle custom domain mappings
···
455
109
// Unmap domains that are no longer selected
456
110
for (const domain of currentlyMappedCustomDomains) {
457
111
if (!selectedCustomDomainIds.includes(domain.id)) {
458
458
-
await fetch(`/api/domain/custom/${domain.id}/map-site`, {
459
459
-
method: 'POST',
460
460
-
headers: { 'Content-Type': 'application/json' },
461
461
-
body: JSON.stringify({ siteRkey: null })
462
462
-
})
112
112
+
await mapCustomDomain(domain.id, null)
463
113
}
464
114
}
465
115
···
467
117
for (const domainId of selectedCustomDomainIds) {
468
118
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
469
119
if (!isAlreadyMapped) {
470
470
-
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
471
471
-
method: 'POST',
472
472
-
headers: { 'Content-Type': 'application/json' },
473
473
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
474
474
-
})
475
475
-
const data = await response.json()
476
476
-
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
120
120
+
await mapCustomDomain(domainId, configuringSite.rkey)
477
121
}
478
122
}
479
123
···
499
143
}
500
144
501
145
setIsDeletingSite(true)
502
502
-
try {
503
503
-
const response = await fetch(`/api/site/${configuringSite.rkey}`, {
504
504
-
method: 'DELETE'
505
505
-
})
506
506
-
507
507
-
const data = await response.json()
508
508
-
if (data.success) {
509
509
-
// Refresh sites list
510
510
-
await fetchSites()
511
511
-
// Refresh domains in case this site was mapped
512
512
-
await fetchDomains()
513
513
-
setConfiguringSite(null)
514
514
-
} else {
515
515
-
throw new Error(data.error || 'Failed to delete site')
516
516
-
}
517
517
-
} catch (err) {
518
518
-
console.error('Delete site error:', err)
519
519
-
alert(
520
520
-
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
521
521
-
)
522
522
-
} finally {
523
523
-
setIsDeletingSite(false)
146
146
+
const success = await deleteSite(configuringSite.rkey)
147
147
+
if (success) {
148
148
+
// Refresh domains in case this site was mapped
149
149
+
await fetchDomains()
150
150
+
setConfiguringSite(null)
524
151
}
152
152
+
setIsDeletingSite(false)
525
153
}
526
154
527
527
-
const checkWispAvailability = async (handle: string) => {
528
528
-
const trimmedHandle = handle.trim().toLowerCase()
529
529
-
if (!trimmedHandle) {
530
530
-
setWispAvailability({ available: null, checking: false })
531
531
-
return
532
532
-
}
533
533
-
534
534
-
setWispAvailability({ available: null, checking: true })
535
535
-
try {
536
536
-
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
537
537
-
const data = await response.json()
538
538
-
setWispAvailability({ available: data.available, checking: false })
539
539
-
} catch (err) {
540
540
-
console.error('Check availability error:', err)
541
541
-
setWispAvailability({ available: false, checking: false })
542
542
-
}
543
543
-
}
544
544
-
545
545
-
const handleClaimWispDomain = async () => {
546
546
-
const trimmedHandle = wispHandle.trim().toLowerCase()
547
547
-
if (!trimmedHandle) {
548
548
-
alert('Please enter a handle')
549
549
-
return
550
550
-
}
551
551
-
552
552
-
setIsClaimingWisp(true)
553
553
-
try {
554
554
-
const response = await fetch('/api/domain/claim', {
555
555
-
method: 'POST',
556
556
-
headers: { 'Content-Type': 'application/json' },
557
557
-
body: JSON.stringify({ handle: trimmedHandle })
558
558
-
})
559
559
-
560
560
-
const data = await response.json()
561
561
-
if (data.success) {
562
562
-
setWispHandle('')
563
563
-
setWispAvailability({ available: null, checking: false })
564
564
-
await fetchDomains()
565
565
-
} else {
566
566
-
throw new Error(data.error || 'Failed to claim domain')
567
567
-
}
568
568
-
} catch (err) {
569
569
-
console.error('Claim domain error:', err)
570
570
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
571
571
-
572
572
-
// Handle "Already claimed" error more gracefully
573
573
-
if (errorMessage.includes('Already claimed')) {
574
574
-
alert('You have already claimed a wisp.place subdomain. Please refresh the page.')
575
575
-
await fetchDomains()
576
576
-
} else {
577
577
-
alert(`Failed to claim domain: ${errorMessage}`)
578
578
-
}
579
579
-
} finally {
580
580
-
setIsClaimingWisp(false)
581
581
-
}
155
155
+
const handleUploadComplete = async () => {
156
156
+
await fetchSites()
582
157
}
583
158
584
159
if (loading) {
···
627
202
</TabsList>
628
203
629
204
{/* Sites Tab */}
630
630
-
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
631
631
-
<Card>
632
632
-
<CardHeader>
633
633
-
<div className="flex items-center justify-between">
634
634
-
<div>
635
635
-
<CardTitle>Your Sites</CardTitle>
636
636
-
<CardDescription>
637
637
-
View and manage all your deployed sites
638
638
-
</CardDescription>
639
639
-
</div>
640
640
-
<Button
641
641
-
variant="outline"
642
642
-
size="sm"
643
643
-
onClick={syncSites}
644
644
-
disabled={isSyncing || sitesLoading}
645
645
-
>
646
646
-
<RefreshCw
647
647
-
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
648
648
-
/>
649
649
-
Sync from PDS
650
650
-
</Button>
651
651
-
</div>
652
652
-
</CardHeader>
653
653
-
<CardContent className="space-y-4">
654
654
-
{sitesLoading ? (
655
655
-
<div className="flex items-center justify-center py-8">
656
656
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
657
657
-
</div>
658
658
-
) : sites.length === 0 ? (
659
659
-
<div className="text-center py-8 text-muted-foreground">
660
660
-
<p>No sites yet. Upload your first site!</p>
661
661
-
</div>
662
662
-
) : (
663
663
-
sites.map((site) => (
664
664
-
<div
665
665
-
key={`${site.did}-${site.rkey}`}
666
666
-
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
667
667
-
>
668
668
-
<div className="flex-1">
669
669
-
<div className="flex items-center gap-3 mb-2">
670
670
-
<h3 className="font-semibold text-lg">
671
671
-
{site.display_name || site.rkey}
672
672
-
</h3>
673
673
-
<Badge
674
674
-
variant="secondary"
675
675
-
className="text-xs"
676
676
-
>
677
677
-
active
678
678
-
</Badge>
679
679
-
</div>
680
680
-
681
681
-
{/* Display all mapped domains */}
682
682
-
{site.domains && site.domains.length > 0 ? (
683
683
-
<div className="space-y-1">
684
684
-
{site.domains.map((domainInfo, idx) => (
685
685
-
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
686
686
-
<a
687
687
-
href={`https://${domainInfo.domain}`}
688
688
-
target="_blank"
689
689
-
rel="noopener noreferrer"
690
690
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
691
691
-
>
692
692
-
<Globe className="w-3 h-3" />
693
693
-
{domainInfo.domain}
694
694
-
<ExternalLink className="w-3 h-3" />
695
695
-
</a>
696
696
-
<Badge
697
697
-
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
698
698
-
className="text-xs"
699
699
-
>
700
700
-
{domainInfo.type}
701
701
-
</Badge>
702
702
-
{domainInfo.type === 'custom' && (
703
703
-
<Badge
704
704
-
variant={domainInfo.verified ? 'default' : 'secondary'}
705
705
-
className="text-xs"
706
706
-
>
707
707
-
{domainInfo.verified ? (
708
708
-
<>
709
709
-
<CheckCircle2 className="w-3 h-3 mr-1" />
710
710
-
verified
711
711
-
</>
712
712
-
) : (
713
713
-
<>
714
714
-
<AlertCircle className="w-3 h-3 mr-1" />
715
715
-
pending
716
716
-
</>
717
717
-
)}
718
718
-
</Badge>
719
719
-
)}
720
720
-
</div>
721
721
-
))}
722
722
-
</div>
723
723
-
) : (
724
724
-
<a
725
725
-
href={getSiteUrl(site)}
726
726
-
target="_blank"
727
727
-
rel="noopener noreferrer"
728
728
-
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
729
729
-
>
730
730
-
{getSiteDomainName(site)}
731
731
-
<ExternalLink className="w-3 h-3" />
732
732
-
</a>
733
733
-
)}
734
734
-
</div>
735
735
-
<Button
736
736
-
variant="outline"
737
737
-
size="sm"
738
738
-
onClick={() => handleConfigureSite(site)}
739
739
-
>
740
740
-
<Settings className="w-4 h-4 mr-2" />
741
741
-
Configure
742
742
-
</Button>
743
743
-
</div>
744
744
-
))
745
745
-
)}
746
746
-
</CardContent>
747
747
-
</Card>
748
748
-
749
749
-
<div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
750
750
-
<div className="flex items-start gap-2">
751
751
-
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
752
752
-
<div className="flex-1 space-y-1">
753
753
-
<p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
754
754
-
Note about sites.wisp.place URLs
755
755
-
</p>
756
756
-
<p className="text-xs text-muted-foreground">
757
757
-
Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths.
758
758
-
</p>
759
759
-
</div>
760
760
-
</div>
761
761
-
</div>
205
205
+
<TabsContent value="sites">
206
206
+
<SitesTab
207
207
+
sites={sites}
208
208
+
sitesLoading={sitesLoading}
209
209
+
isSyncing={isSyncing}
210
210
+
userInfo={userInfo}
211
211
+
onSyncSites={syncSites}
212
212
+
onConfigureSite={handleConfigureSite}
213
213
+
/>
762
214
</TabsContent>
763
215
764
216
{/* Domains Tab */}
765
765
-
<TabsContent value="domains" className="space-y-4 min-h-[400px]">
766
766
-
<Card>
767
767
-
<CardHeader>
768
768
-
<CardTitle>wisp.place Subdomain</CardTitle>
769
769
-
<CardDescription>
770
770
-
Your free subdomain on the wisp.place network
771
771
-
</CardDescription>
772
772
-
</CardHeader>
773
773
-
<CardContent>
774
774
-
{domainsLoading ? (
775
775
-
<div className="flex items-center justify-center py-4">
776
776
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
777
777
-
</div>
778
778
-
) : wispDomain ? (
779
779
-
<>
780
780
-
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
781
781
-
<div className="flex items-center gap-2">
782
782
-
<CheckCircle2 className="w-5 h-5 text-green-500" />
783
783
-
<span className="font-mono text-lg">
784
784
-
{wispDomain.domain}
785
785
-
</span>
786
786
-
</div>
787
787
-
{wispDomain.rkey && (
788
788
-
<p className="text-xs text-muted-foreground ml-7">
789
789
-
→ Mapped to site: {wispDomain.rkey}
790
790
-
</p>
791
791
-
)}
792
792
-
</div>
793
793
-
<p className="text-sm text-muted-foreground mt-3">
794
794
-
{wispDomain.rkey
795
795
-
? 'This domain is mapped to a specific site'
796
796
-
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
797
797
-
</p>
798
798
-
</>
799
799
-
) : (
800
800
-
<div className="space-y-4">
801
801
-
<div className="p-4 bg-muted/30 rounded-lg">
802
802
-
<p className="text-sm text-muted-foreground mb-4">
803
803
-
Claim your free wisp.place subdomain
804
804
-
</p>
805
805
-
<div className="space-y-3">
806
806
-
<div className="space-y-2">
807
807
-
<Label htmlFor="wisp-handle">Choose your handle</Label>
808
808
-
<div className="flex gap-2">
809
809
-
<div className="flex-1 relative">
810
810
-
<Input
811
811
-
id="wisp-handle"
812
812
-
placeholder="mysite"
813
813
-
value={wispHandle}
814
814
-
onChange={(e) => {
815
815
-
setWispHandle(e.target.value)
816
816
-
if (e.target.value.trim()) {
817
817
-
checkWispAvailability(e.target.value)
818
818
-
} else {
819
819
-
setWispAvailability({ available: null, checking: false })
820
820
-
}
821
821
-
}}
822
822
-
disabled={isClaimingWisp}
823
823
-
className="pr-24"
824
824
-
/>
825
825
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
826
826
-
.wisp.place
827
827
-
</span>
828
828
-
</div>
829
829
-
</div>
830
830
-
{wispAvailability.checking && (
831
831
-
<p className="text-xs text-muted-foreground flex items-center gap-1">
832
832
-
<Loader2 className="w-3 h-3 animate-spin" />
833
833
-
Checking availability...
834
834
-
</p>
835
835
-
)}
836
836
-
{!wispAvailability.checking && wispAvailability.available === true && (
837
837
-
<p className="text-xs text-green-600 flex items-center gap-1">
838
838
-
<CheckCircle2 className="w-3 h-3" />
839
839
-
Available
840
840
-
</p>
841
841
-
)}
842
842
-
{!wispAvailability.checking && wispAvailability.available === false && (
843
843
-
<p className="text-xs text-red-600 flex items-center gap-1">
844
844
-
<XCircle className="w-3 h-3" />
845
845
-
Not available
846
846
-
</p>
847
847
-
)}
848
848
-
</div>
849
849
-
<Button
850
850
-
onClick={handleClaimWispDomain}
851
851
-
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
852
852
-
className="w-full"
853
853
-
>
854
854
-
{isClaimingWisp ? (
855
855
-
<>
856
856
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
857
857
-
Claiming...
858
858
-
</>
859
859
-
) : (
860
860
-
'Claim Subdomain'
861
861
-
)}
862
862
-
</Button>
863
863
-
</div>
864
864
-
</div>
865
865
-
</div>
866
866
-
)}
867
867
-
</CardContent>
868
868
-
</Card>
869
869
-
870
870
-
<Card>
871
871
-
<CardHeader>
872
872
-
<CardTitle>Custom Domains</CardTitle>
873
873
-
<CardDescription>
874
874
-
Bring your own domain with DNS verification
875
875
-
</CardDescription>
876
876
-
</CardHeader>
877
877
-
<CardContent className="space-y-4">
878
878
-
<Button
879
879
-
onClick={() => setAddDomainModalOpen(true)}
880
880
-
className="w-full"
881
881
-
>
882
882
-
Add Custom Domain
883
883
-
</Button>
884
884
-
885
885
-
{domainsLoading ? (
886
886
-
<div className="flex items-center justify-center py-4">
887
887
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
888
888
-
</div>
889
889
-
) : customDomains.length === 0 ? (
890
890
-
<div className="text-center py-4 text-muted-foreground text-sm">
891
891
-
No custom domains added yet
892
892
-
</div>
893
893
-
) : (
894
894
-
<div className="space-y-2">
895
895
-
{customDomains.map((domain) => (
896
896
-
<div
897
897
-
key={domain.id}
898
898
-
className="flex items-center justify-between p-3 border border-border rounded-lg"
899
899
-
>
900
900
-
<div className="flex flex-col gap-1 flex-1">
901
901
-
<div className="flex items-center gap-2">
902
902
-
{domain.verified ? (
903
903
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
904
904
-
) : (
905
905
-
<XCircle className="w-4 h-4 text-red-500" />
906
906
-
)}
907
907
-
<span className="font-mono">
908
908
-
{domain.domain}
909
909
-
</span>
910
910
-
</div>
911
911
-
{domain.rkey && domain.rkey !== 'self' && (
912
912
-
<p className="text-xs text-muted-foreground ml-6">
913
913
-
→ Mapped to site: {domain.rkey}
914
914
-
</p>
915
915
-
)}
916
916
-
</div>
917
917
-
<div className="flex items-center gap-2">
918
918
-
<Button
919
919
-
variant="outline"
920
920
-
size="sm"
921
921
-
onClick={() =>
922
922
-
setViewDomainDNS(domain.id)
923
923
-
}
924
924
-
>
925
925
-
View DNS
926
926
-
</Button>
927
927
-
{domain.verified ? (
928
928
-
<Badge variant="secondary">
929
929
-
Verified
930
930
-
</Badge>
931
931
-
) : (
932
932
-
<Button
933
933
-
variant="outline"
934
934
-
size="sm"
935
935
-
onClick={() =>
936
936
-
handleVerifyDomain(domain.id)
937
937
-
}
938
938
-
disabled={
939
939
-
verificationStatus[
940
940
-
domain.id
941
941
-
] === 'verifying'
942
942
-
}
943
943
-
>
944
944
-
{verificationStatus[
945
945
-
domain.id
946
946
-
] === 'verifying' ? (
947
947
-
<>
948
948
-
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
949
949
-
Verifying...
950
950
-
</>
951
951
-
) : (
952
952
-
'Verify DNS'
953
953
-
)}
954
954
-
</Button>
955
955
-
)}
956
956
-
<Button
957
957
-
variant="ghost"
958
958
-
size="sm"
959
959
-
onClick={() =>
960
960
-
handleDeleteCustomDomain(
961
961
-
domain.id
962
962
-
)
963
963
-
}
964
964
-
>
965
965
-
<Trash2 className="w-4 h-4" />
966
966
-
</Button>
967
967
-
</div>
968
968
-
</div>
969
969
-
))}
970
970
-
</div>
971
971
-
)}
972
972
-
</CardContent>
973
973
-
</Card>
217
217
+
<TabsContent value="domains">
218
218
+
<DomainsTab
219
219
+
wispDomain={wispDomain}
220
220
+
customDomains={customDomains}
221
221
+
domainsLoading={domainsLoading}
222
222
+
verificationStatus={verificationStatus}
223
223
+
userInfo={userInfo}
224
224
+
onAddCustomDomain={addCustomDomain}
225
225
+
onVerifyDomain={verifyDomain}
226
226
+
onDeleteCustomDomain={deleteCustomDomain}
227
227
+
onClaimWispDomain={claimWispDomain}
228
228
+
onCheckWispAvailability={checkWispAvailability}
229
229
+
/>
974
230
</TabsContent>
975
231
976
232
{/* Upload Tab */}
977
977
-
<TabsContent value="upload" className="space-y-4 min-h-[400px]">
978
978
-
<Card>
979
979
-
<CardHeader>
980
980
-
<CardTitle>Upload Site</CardTitle>
981
981
-
<CardDescription>
982
982
-
Deploy a new site from a folder or Git repository
983
983
-
</CardDescription>
984
984
-
</CardHeader>
985
985
-
<CardContent className="space-y-6">
986
986
-
<div className="space-y-4">
987
987
-
<div className="p-4 bg-muted/50 rounded-lg">
988
988
-
<RadioGroup
989
989
-
value={siteMode}
990
990
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
991
991
-
disabled={isUploading}
992
992
-
>
993
993
-
<div className="flex items-center space-x-2">
994
994
-
<RadioGroupItem value="existing" id="existing" />
995
995
-
<Label htmlFor="existing" className="cursor-pointer">
996
996
-
Update existing site
997
997
-
</Label>
998
998
-
</div>
999
999
-
<div className="flex items-center space-x-2">
1000
1000
-
<RadioGroupItem value="new" id="new" />
1001
1001
-
<Label htmlFor="new" className="cursor-pointer">
1002
1002
-
Create new site
1003
1003
-
</Label>
1004
1004
-
</div>
1005
1005
-
</RadioGroup>
1006
1006
-
</div>
1007
1007
-
1008
1008
-
{siteMode === 'existing' ? (
1009
1009
-
<div className="space-y-2">
1010
1010
-
<Label htmlFor="site-select">Select Site</Label>
1011
1011
-
{sitesLoading ? (
1012
1012
-
<div className="flex items-center justify-center py-4">
1013
1013
-
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
1014
1014
-
</div>
1015
1015
-
) : sites.length === 0 ? (
1016
1016
-
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
1017
1017
-
No sites available. Create a new site instead.
1018
1018
-
</div>
1019
1019
-
) : (
1020
1020
-
<select
1021
1021
-
id="site-select"
1022
1022
-
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
1023
1023
-
value={selectedSiteRkey}
1024
1024
-
onChange={(e) => setSelectedSiteRkey(e.target.value)}
1025
1025
-
disabled={isUploading}
1026
1026
-
>
1027
1027
-
<option value="">Select a site...</option>
1028
1028
-
{sites.map((site) => (
1029
1029
-
<option key={site.rkey} value={site.rkey}>
1030
1030
-
{site.display_name || site.rkey}
1031
1031
-
</option>
1032
1032
-
))}
1033
1033
-
</select>
1034
1034
-
)}
1035
1035
-
</div>
1036
1036
-
) : (
1037
1037
-
<div className="space-y-2">
1038
1038
-
<Label htmlFor="new-site-name">New Site Name</Label>
1039
1039
-
<Input
1040
1040
-
id="new-site-name"
1041
1041
-
placeholder="my-awesome-site"
1042
1042
-
value={newSiteName}
1043
1043
-
onChange={(e) => setNewSiteName(e.target.value)}
1044
1044
-
disabled={isUploading}
1045
1045
-
/>
1046
1046
-
</div>
1047
1047
-
)}
1048
1048
-
1049
1049
-
<p className="text-xs text-muted-foreground">
1050
1050
-
File limits: 100MB per file, 300MB total
1051
1051
-
</p>
1052
1052
-
</div>
1053
1053
-
1054
1054
-
<div className="grid md:grid-cols-2 gap-4">
1055
1055
-
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
1056
1056
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
1057
1057
-
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
1058
1058
-
<h3 className="font-semibold mb-2">
1059
1059
-
Upload Folder
1060
1060
-
</h3>
1061
1061
-
<p className="text-sm text-muted-foreground mb-4">
1062
1062
-
Drag and drop or click to upload your
1063
1063
-
static site files
1064
1064
-
</p>
1065
1065
-
<input
1066
1066
-
type="file"
1067
1067
-
id="file-upload"
1068
1068
-
multiple
1069
1069
-
onChange={handleFileSelect}
1070
1070
-
className="hidden"
1071
1071
-
{...(({ webkitdirectory: '', directory: '' } as any))}
1072
1072
-
disabled={isUploading}
1073
1073
-
/>
1074
1074
-
<label htmlFor="file-upload">
1075
1075
-
<Button
1076
1076
-
variant="outline"
1077
1077
-
type="button"
1078
1078
-
onClick={() =>
1079
1079
-
document
1080
1080
-
.getElementById('file-upload')
1081
1081
-
?.click()
1082
1082
-
}
1083
1083
-
disabled={isUploading}
1084
1084
-
>
1085
1085
-
Choose Folder
1086
1086
-
</Button>
1087
1087
-
</label>
1088
1088
-
{selectedFiles && selectedFiles.length > 0 && (
1089
1089
-
<p className="text-sm text-muted-foreground mt-3">
1090
1090
-
{selectedFiles.length} files selected
1091
1091
-
</p>
1092
1092
-
)}
1093
1093
-
</CardContent>
1094
1094
-
</Card>
1095
1095
-
1096
1096
-
<Card className="border-2 border-dashed opacity-50">
1097
1097
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
1098
1098
-
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
1099
1099
-
<h3 className="font-semibold mb-2">
1100
1100
-
Connect Git Repository
1101
1101
-
</h3>
1102
1102
-
<p className="text-sm text-muted-foreground mb-4">
1103
1103
-
Link your GitHub, GitLab, or any Git
1104
1104
-
repository
1105
1105
-
</p>
1106
1106
-
<Badge variant="secondary">Coming soon!</Badge>
1107
1107
-
</CardContent>
1108
1108
-
</Card>
1109
1109
-
</div>
1110
1110
-
1111
1111
-
{uploadProgress && (
1112
1112
-
<div className="space-y-3">
1113
1113
-
<div className="p-4 bg-muted rounded-lg">
1114
1114
-
<div className="flex items-center gap-2">
1115
1115
-
<Loader2 className="w-4 h-4 animate-spin" />
1116
1116
-
<span className="text-sm">{uploadProgress}</span>
1117
1117
-
</div>
1118
1118
-
</div>
1119
1119
-
1120
1120
-
{skippedFiles.length > 0 && (
1121
1121
-
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
1122
1122
-
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
1123
1123
-
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
1124
1124
-
<div className="flex-1">
1125
1125
-
<span className="font-medium">
1126
1126
-
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
1127
1127
-
</span>
1128
1128
-
{uploadedCount > 0 && (
1129
1129
-
<span className="text-sm ml-2">
1130
1130
-
({uploadedCount} uploaded successfully)
1131
1131
-
</span>
1132
1132
-
)}
1133
1133
-
</div>
1134
1134
-
</div>
1135
1135
-
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
1136
1136
-
{skippedFiles.slice(0, 5).map((file, idx) => (
1137
1137
-
<div key={idx} className="text-xs">
1138
1138
-
<span className="font-mono">{file.name}</span>
1139
1139
-
<span className="text-muted-foreground"> - {file.reason}</span>
1140
1140
-
</div>
1141
1141
-
))}
1142
1142
-
{skippedFiles.length > 5 && (
1143
1143
-
<div className="text-xs text-muted-foreground">
1144
1144
-
...and {skippedFiles.length - 5} more
1145
1145
-
</div>
1146
1146
-
)}
1147
1147
-
</div>
1148
1148
-
</div>
1149
1149
-
)}
1150
1150
-
</div>
1151
1151
-
)}
1152
1152
-
1153
1153
-
<Button
1154
1154
-
onClick={handleUpload}
1155
1155
-
className="w-full"
1156
1156
-
disabled={
1157
1157
-
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
1158
1158
-
isUploading ||
1159
1159
-
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
1160
1160
-
}
1161
1161
-
>
1162
1162
-
{isUploading ? (
1163
1163
-
<>
1164
1164
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
1165
1165
-
Uploading...
1166
1166
-
</>
1167
1167
-
) : (
1168
1168
-
<>
1169
1169
-
{siteMode === 'existing' ? (
1170
1170
-
'Update Site'
1171
1171
-
) : (
1172
1172
-
selectedFiles && selectedFiles.length > 0
1173
1173
-
? 'Upload & Deploy'
1174
1174
-
: 'Create Empty Site'
1175
1175
-
)}
1176
1176
-
</>
1177
1177
-
)}
1178
1178
-
</Button>
1179
1179
-
</CardContent>
1180
1180
-
</Card>
233
233
+
<TabsContent value="upload">
234
234
+
<UploadTab
235
235
+
sites={sites}
236
236
+
sitesLoading={sitesLoading}
237
237
+
onUploadComplete={handleUploadComplete}
238
238
+
/>
1181
239
</TabsContent>
1182
240
1183
241
{/* CLI Tab */}
1184
1184
-
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
1185
1185
-
<Card>
1186
1186
-
<CardHeader>
1187
1187
-
<div className="flex items-center gap-2 mb-2">
1188
1188
-
<CardTitle>Wisp CLI Tool</CardTitle>
1189
1189
-
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
1190
1190
-
<Badge variant="outline" className="text-xs">Alpha</Badge>
1191
1191
-
</div>
1192
1192
-
<CardDescription>
1193
1193
-
Deploy static sites directly from your terminal
1194
1194
-
</CardDescription>
1195
1195
-
</CardHeader>
1196
1196
-
<CardContent className="space-y-6">
1197
1197
-
<div className="prose prose-sm max-w-none dark:prose-invert">
1198
1198
-
<p className="text-sm text-muted-foreground">
1199
1199
-
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
1200
1200
-
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
1201
1201
-
</p>
1202
1202
-
</div>
1203
1203
-
1204
1204
-
<div className="space-y-3">
1205
1205
-
<h3 className="text-sm font-semibold">Download CLI</h3>
1206
1206
-
<div className="grid gap-2">
1207
1207
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1208
1208
-
<a
1209
1209
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
1210
1210
-
target="_blank"
1211
1211
-
rel="noopener noreferrer"
1212
1212
-
className="flex items-center justify-between mb-2"
1213
1213
-
>
1214
1214
-
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
1215
1215
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1216
1216
-
</a>
1217
1217
-
<div className="text-xs text-muted-foreground">
1218
1218
-
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
1219
1219
-
</div>
1220
1220
-
</div>
1221
1221
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1222
1222
-
<a
1223
1223
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
1224
1224
-
target="_blank"
1225
1225
-
rel="noopener noreferrer"
1226
1226
-
className="flex items-center justify-between mb-2"
1227
1227
-
>
1228
1228
-
<span className="font-mono text-sm">Linux (ARM64)</span>
1229
1229
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1230
1230
-
</a>
1231
1231
-
<div className="text-xs text-muted-foreground">
1232
1232
-
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
1233
1233
-
</div>
1234
1234
-
</div>
1235
1235
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1236
1236
-
<a
1237
1237
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
1238
1238
-
target="_blank"
1239
1239
-
rel="noopener noreferrer"
1240
1240
-
className="flex items-center justify-between mb-2"
1241
1241
-
>
1242
1242
-
<span className="font-mono text-sm">Linux (x86_64)</span>
1243
1243
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1244
1244
-
</a>
1245
1245
-
<div className="text-xs text-muted-foreground">
1246
1246
-
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
1247
1247
-
</div>
1248
1248
-
</div>
1249
1249
-
</div>
1250
1250
-
</div>
1251
1251
-
1252
1252
-
<div className="space-y-3">
1253
1253
-
<h3 className="text-sm font-semibold">Basic Usage</h3>
1254
1254
-
<CodeBlock
1255
1255
-
code={`# Download and make executable
1256
1256
-
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
1257
1257
-
chmod +x wisp-cli-macos-arm64
1258
1258
-
1259
1259
-
# Deploy your site (will use OAuth)
1260
1260
-
./wisp-cli-macos-arm64 your-handle.bsky.social \\
1261
1261
-
--path ./dist \\
1262
1262
-
--site my-site
1263
1263
-
1264
1264
-
# Your site will be available at:
1265
1265
-
# https://sites.wisp.place/your-handle/my-site`}
1266
1266
-
language="bash"
1267
1267
-
/>
1268
1268
-
</div>
1269
1269
-
1270
1270
-
<div className="space-y-3">
1271
1271
-
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
1272
1272
-
<p className="text-xs text-muted-foreground">
1273
1273
-
Deploy automatically on every push using{' '}
1274
1274
-
<a
1275
1275
-
href="https://blog.tangled.org/ci"
1276
1276
-
target="_blank"
1277
1277
-
rel="noopener noreferrer"
1278
1278
-
className="text-accent hover:underline"
1279
1279
-
>
1280
1280
-
Tangled Spindle
1281
1281
-
</a>
1282
1282
-
</p>
1283
1283
-
1284
1284
-
<div className="space-y-4">
1285
1285
-
<div>
1286
1286
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1287
1287
-
<span>Example 1: Simple Asset Publishing</span>
1288
1288
-
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
1289
1289
-
</h4>
1290
1290
-
<CodeBlock
1291
1291
-
code={`when:
1292
1292
-
- event: ['push']
1293
1293
-
branch: ['main']
1294
1294
-
- event: ['manual']
1295
1295
-
1296
1296
-
engine: 'nixery'
1297
1297
-
1298
1298
-
clone:
1299
1299
-
skip: false
1300
1300
-
depth: 1
1301
1301
-
1302
1302
-
dependencies:
1303
1303
-
nixpkgs:
1304
1304
-
- coreutils
1305
1305
-
- curl
1306
1306
-
1307
1307
-
environment:
1308
1308
-
SITE_PATH: '.' # Copy entire repo
1309
1309
-
SITE_NAME: 'myWebbedSite'
1310
1310
-
WISP_HANDLE: 'your-handle.bsky.social'
1311
1311
-
1312
1312
-
steps:
1313
1313
-
- name: deploy assets to wisp
1314
1314
-
command: |
1315
1315
-
# Download Wisp CLI
1316
1316
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1317
1317
-
chmod +x wisp-cli
1318
1318
-
1319
1319
-
# Deploy to Wisp
1320
1320
-
./wisp-cli \\
1321
1321
-
"$WISP_HANDLE" \\
1322
1322
-
--path "$SITE_PATH" \\
1323
1323
-
--site "$SITE_NAME" \\
1324
1324
-
--password "$WISP_APP_PASSWORD"
1325
1325
-
1326
1326
-
# Output
1327
1327
-
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
1328
1328
-
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
1329
1329
-
`}
1330
1330
-
language="yaml"
1331
1331
-
/>
1332
1332
-
</div>
1333
1333
-
1334
1334
-
<div>
1335
1335
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1336
1336
-
<span>Example 2: React/Vite Build & Deploy</span>
1337
1337
-
<Badge variant="secondary" className="text-xs">Full Build</Badge>
1338
1338
-
</h4>
1339
1339
-
<CodeBlock
1340
1340
-
code={`when:
1341
1341
-
- event: ['push']
1342
1342
-
branch: ['main']
1343
1343
-
- event: ['manual']
1344
1344
-
1345
1345
-
engine: 'nixery'
1346
1346
-
1347
1347
-
clone:
1348
1348
-
skip: false
1349
1349
-
depth: 1
1350
1350
-
submodules: false
1351
1351
-
1352
1352
-
dependencies:
1353
1353
-
nixpkgs:
1354
1354
-
- nodejs
1355
1355
-
- coreutils
1356
1356
-
- curl
1357
1357
-
github:NixOS/nixpkgs/nixpkgs-unstable:
1358
1358
-
- bun
1359
1359
-
1360
1360
-
environment:
1361
1361
-
SITE_PATH: 'dist'
1362
1362
-
SITE_NAME: 'my-react-site'
1363
1363
-
WISP_HANDLE: 'your-handle.bsky.social'
1364
1364
-
1365
1365
-
steps:
1366
1366
-
- name: build site
1367
1367
-
command: |
1368
1368
-
# necessary to ensure bun is in PATH
1369
1369
-
export PATH="$HOME/.nix-profile/bin:$PATH"
1370
1370
-
1371
1371
-
bun install --frozen-lockfile
1372
1372
-
1373
1373
-
# build with vite, run directly to get around env issues
1374
1374
-
bun node_modules/.bin/vite build
1375
1375
-
1376
1376
-
- name: deploy to wisp
1377
1377
-
command: |
1378
1378
-
# Download Wisp CLI
1379
1379
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1380
1380
-
chmod +x wisp-cli
1381
1381
-
1382
1382
-
# Deploy to Wisp
1383
1383
-
./wisp-cli \\
1384
1384
-
"$WISP_HANDLE" \\
1385
1385
-
--path "$SITE_PATH" \\
1386
1386
-
--site "$SITE_NAME" \\
1387
1387
-
--password "$WISP_APP_PASSWORD"`}
1388
1388
-
language="yaml"
1389
1389
-
/>
1390
1390
-
</div>
1391
1391
-
</div>
1392
1392
-
1393
1393
-
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
1394
1394
-
<p className="text-xs text-muted-foreground">
1395
1395
-
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
1396
1396
-
Generate an app password from your AT Protocol account settings.
1397
1397
-
</p>
1398
1398
-
</div>
1399
1399
-
</div>
1400
1400
-
1401
1401
-
<div className="space-y-3">
1402
1402
-
<h3 className="text-sm font-semibold">Learn More</h3>
1403
1403
-
<div className="grid gap-2">
1404
1404
-
<a
1405
1405
-
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
1406
1406
-
target="_blank"
1407
1407
-
rel="noopener noreferrer"
1408
1408
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1409
1409
-
>
1410
1410
-
<span className="text-sm">Source Code</span>
1411
1411
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1412
1412
-
</a>
1413
1413
-
<a
1414
1414
-
href="https://blog.tangled.org/ci"
1415
1415
-
target="_blank"
1416
1416
-
rel="noopener noreferrer"
1417
1417
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1418
1418
-
>
1419
1419
-
<span className="text-sm">Tangled Spindle CI/CD</span>
1420
1420
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1421
1421
-
</a>
1422
1422
-
</div>
1423
1423
-
</div>
1424
1424
-
</CardContent>
1425
1425
-
</Card>
242
242
+
<TabsContent value="cli">
243
243
+
<CLITab />
1426
244
</TabsContent>
1427
245
</Tabs>
1428
246
</div>
1429
1429
-
1430
1430
-
{/* Add Custom Domain Modal */}
1431
1431
-
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
1432
1432
-
<DialogContent className="sm:max-w-lg">
1433
1433
-
<DialogHeader>
1434
1434
-
<DialogTitle>Add Custom Domain</DialogTitle>
1435
1435
-
<DialogDescription>
1436
1436
-
Enter your domain name. After adding, you'll see the DNS
1437
1437
-
records to configure.
1438
1438
-
</DialogDescription>
1439
1439
-
</DialogHeader>
1440
1440
-
<div className="space-y-4 py-4">
1441
1441
-
<div className="space-y-2">
1442
1442
-
<Label htmlFor="new-domain">Domain Name</Label>
1443
1443
-
<Input
1444
1444
-
id="new-domain"
1445
1445
-
placeholder="example.com"
1446
1446
-
value={customDomain}
1447
1447
-
onChange={(e) => setCustomDomain(e.target.value)}
1448
1448
-
/>
1449
1449
-
<p className="text-xs text-muted-foreground">
1450
1450
-
After adding, click "View DNS" to see the records you
1451
1451
-
need to configure.
1452
1452
-
</p>
1453
1453
-
</div>
1454
1454
-
</div>
1455
1455
-
<DialogFooter className="flex-col sm:flex-row gap-2">
1456
1456
-
<Button
1457
1457
-
variant="outline"
1458
1458
-
onClick={() => {
1459
1459
-
setAddDomainModalOpen(false)
1460
1460
-
setCustomDomain('')
1461
1461
-
}}
1462
1462
-
className="w-full sm:w-auto"
1463
1463
-
disabled={isAddingDomain}
1464
1464
-
>
1465
1465
-
Cancel
1466
1466
-
</Button>
1467
1467
-
<Button
1468
1468
-
onClick={handleAddCustomDomain}
1469
1469
-
disabled={!customDomain || isAddingDomain}
1470
1470
-
className="w-full sm:w-auto"
1471
1471
-
>
1472
1472
-
{isAddingDomain ? (
1473
1473
-
<>
1474
1474
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
1475
1475
-
Adding...
1476
1476
-
</>
1477
1477
-
) : (
1478
1478
-
'Add Domain'
1479
1479
-
)}
1480
1480
-
</Button>
1481
1481
-
</DialogFooter>
1482
1482
-
</DialogContent>
1483
1483
-
</Dialog>
1484
247
1485
248
{/* Site Configuration Modal */}
1486
249
<Dialog
···
1637
400
)}
1638
401
</Button>
1639
402
</div>
1640
1640
-
</DialogFooter>
1641
1641
-
</DialogContent>
1642
1642
-
</Dialog>
1643
1643
-
1644
1644
-
{/* View DNS Records Modal */}
1645
1645
-
<Dialog
1646
1646
-
open={viewDomainDNS !== null}
1647
1647
-
onOpenChange={(open) => !open && setViewDomainDNS(null)}
1648
1648
-
>
1649
1649
-
<DialogContent className="sm:max-w-lg">
1650
1650
-
<DialogHeader>
1651
1651
-
<DialogTitle>DNS Configuration</DialogTitle>
1652
1652
-
<DialogDescription>
1653
1653
-
Add these DNS records to your domain provider
1654
1654
-
</DialogDescription>
1655
1655
-
</DialogHeader>
1656
1656
-
{viewDomainDNS && userInfo && (
1657
1657
-
<>
1658
1658
-
{(() => {
1659
1659
-
const domain = customDomains.find(
1660
1660
-
(d) => d.id === viewDomainDNS
1661
1661
-
)
1662
1662
-
if (!domain) return null
1663
1663
-
1664
1664
-
return (
1665
1665
-
<div className="space-y-4 py-4">
1666
1666
-
<div className="p-3 bg-muted/30 rounded-lg">
1667
1667
-
<p className="text-sm font-medium mb-1">
1668
1668
-
Domain:
1669
1669
-
</p>
1670
1670
-
<p className="font-mono text-sm">
1671
1671
-
{domain.domain}
1672
1672
-
</p>
1673
1673
-
</div>
1674
1674
-
1675
1675
-
<div className="space-y-3">
1676
1676
-
<div className="p-3 bg-background rounded border border-border">
1677
1677
-
<div className="flex justify-between items-start mb-2">
1678
1678
-
<span className="text-xs font-semibold text-muted-foreground">
1679
1679
-
TXT Record (Verification)
1680
1680
-
</span>
1681
1681
-
</div>
1682
1682
-
<div className="font-mono text-xs space-y-2">
1683
1683
-
<div>
1684
1684
-
<span className="text-muted-foreground">
1685
1685
-
Name:
1686
1686
-
</span>{' '}
1687
1687
-
<span className="select-all">
1688
1688
-
_wisp.{domain.domain}
1689
1689
-
</span>
1690
1690
-
</div>
1691
1691
-
<div>
1692
1692
-
<span className="text-muted-foreground">
1693
1693
-
Value:
1694
1694
-
</span>{' '}
1695
1695
-
<span className="select-all break-all">
1696
1696
-
{userInfo.did}
1697
1697
-
</span>
1698
1698
-
</div>
1699
1699
-
</div>
1700
1700
-
</div>
1701
1701
-
1702
1702
-
<div className="p-3 bg-background rounded border border-border">
1703
1703
-
<div className="flex justify-between items-start mb-2">
1704
1704
-
<span className="text-xs font-semibold text-muted-foreground">
1705
1705
-
CNAME Record (Pointing)
1706
1706
-
</span>
1707
1707
-
</div>
1708
1708
-
<div className="font-mono text-xs space-y-2">
1709
1709
-
<div>
1710
1710
-
<span className="text-muted-foreground">
1711
1711
-
Name:
1712
1712
-
</span>{' '}
1713
1713
-
<span className="select-all">
1714
1714
-
{domain.domain}
1715
1715
-
</span>
1716
1716
-
</div>
1717
1717
-
<div>
1718
1718
-
<span className="text-muted-foreground">
1719
1719
-
Value:
1720
1720
-
</span>{' '}
1721
1721
-
<span className="select-all">
1722
1722
-
{domain.id}.dns.wisp.place
1723
1723
-
</span>
1724
1724
-
</div>
1725
1725
-
</div>
1726
1726
-
<p className="text-xs text-muted-foreground mt-2">
1727
1727
-
Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification.
1728
1728
-
</p>
1729
1729
-
</div>
1730
1730
-
</div>
1731
1731
-
1732
1732
-
<div className="p-3 bg-muted/30 rounded-lg">
1733
1733
-
<p className="text-xs text-muted-foreground">
1734
1734
-
💡 After configuring DNS, click "Verify DNS"
1735
1735
-
to check if everything is set up correctly.
1736
1736
-
DNS changes can take a few minutes to
1737
1737
-
propagate.
1738
1738
-
</p>
1739
1739
-
</div>
1740
1740
-
</div>
1741
1741
-
)
1742
1742
-
})()}
1743
1743
-
</>
1744
1744
-
)}
1745
1745
-
<DialogFooter>
1746
1746
-
<Button
1747
1747
-
variant="outline"
1748
1748
-
onClick={() => setViewDomainDNS(null)}
1749
1749
-
className="w-full sm:w-auto"
1750
1750
-
>
1751
1751
-
Close
1752
1752
-
</Button>
1753
403
</DialogFooter>
1754
404
</DialogContent>
1755
405
</Dialog>
+212
public/editor/hooks/useDomainData.ts
···
1
1
+
import { useState } from 'react'
2
2
+
3
3
+
export interface CustomDomain {
4
4
+
id: string
5
5
+
domain: string
6
6
+
did: string
7
7
+
rkey: string
8
8
+
verified: boolean
9
9
+
last_verified_at: number | null
10
10
+
created_at: number
11
11
+
}
12
12
+
13
13
+
export interface WispDomain {
14
14
+
domain: string
15
15
+
rkey: string | null
16
16
+
}
17
17
+
18
18
+
type VerificationStatus = 'idle' | 'verifying' | 'success' | 'error'
19
19
+
20
20
+
export function useDomainData() {
21
21
+
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
22
22
+
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
23
23
+
const [domainsLoading, setDomainsLoading] = useState(true)
24
24
+
const [verificationStatus, setVerificationStatus] = useState<{
25
25
+
[id: string]: VerificationStatus
26
26
+
}>({})
27
27
+
28
28
+
const fetchDomains = async () => {
29
29
+
try {
30
30
+
const response = await fetch('/api/user/domains')
31
31
+
const data = await response.json()
32
32
+
setWispDomain(data.wispDomain)
33
33
+
setCustomDomains(data.customDomains || [])
34
34
+
} catch (err) {
35
35
+
console.error('Failed to fetch domains:', err)
36
36
+
} finally {
37
37
+
setDomainsLoading(false)
38
38
+
}
39
39
+
}
40
40
+
41
41
+
const addCustomDomain = async (domain: string) => {
42
42
+
try {
43
43
+
const response = await fetch('/api/domain/custom/add', {
44
44
+
method: 'POST',
45
45
+
headers: { 'Content-Type': 'application/json' },
46
46
+
body: JSON.stringify({ domain })
47
47
+
})
48
48
+
49
49
+
const data = await response.json()
50
50
+
if (data.success) {
51
51
+
await fetchDomains()
52
52
+
return { success: true, id: data.id }
53
53
+
} else {
54
54
+
throw new Error(data.error || 'Failed to add domain')
55
55
+
}
56
56
+
} catch (err) {
57
57
+
console.error('Add domain error:', err)
58
58
+
alert(
59
59
+
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
60
60
+
)
61
61
+
return { success: false }
62
62
+
}
63
63
+
}
64
64
+
65
65
+
const verifyDomain = async (id: string) => {
66
66
+
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
67
67
+
68
68
+
try {
69
69
+
const response = await fetch('/api/domain/custom/verify', {
70
70
+
method: 'POST',
71
71
+
headers: { 'Content-Type': 'application/json' },
72
72
+
body: JSON.stringify({ id })
73
73
+
})
74
74
+
75
75
+
const data = await response.json()
76
76
+
if (data.success && data.verified) {
77
77
+
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
78
78
+
await fetchDomains()
79
79
+
} else {
80
80
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
81
81
+
if (data.error) {
82
82
+
alert(`Verification failed: ${data.error}`)
83
83
+
}
84
84
+
}
85
85
+
} catch (err) {
86
86
+
console.error('Verify domain error:', err)
87
87
+
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
88
88
+
alert(
89
89
+
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
90
90
+
)
91
91
+
}
92
92
+
}
93
93
+
94
94
+
const deleteCustomDomain = async (id: string) => {
95
95
+
if (!confirm('Are you sure you want to remove this custom domain?')) {
96
96
+
return false
97
97
+
}
98
98
+
99
99
+
try {
100
100
+
const response = await fetch(`/api/domain/custom/${id}`, {
101
101
+
method: 'DELETE'
102
102
+
})
103
103
+
104
104
+
const data = await response.json()
105
105
+
if (data.success) {
106
106
+
await fetchDomains()
107
107
+
return true
108
108
+
} else {
109
109
+
throw new Error('Failed to delete domain')
110
110
+
}
111
111
+
} catch (err) {
112
112
+
console.error('Delete domain error:', err)
113
113
+
alert(
114
114
+
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
115
115
+
)
116
116
+
return false
117
117
+
}
118
118
+
}
119
119
+
120
120
+
const mapWispDomain = async (siteRkey: string | null) => {
121
121
+
try {
122
122
+
const response = await fetch('/api/domain/wisp/map-site', {
123
123
+
method: 'POST',
124
124
+
headers: { 'Content-Type': 'application/json' },
125
125
+
body: JSON.stringify({ siteRkey })
126
126
+
})
127
127
+
const data = await response.json()
128
128
+
if (!data.success) throw new Error('Failed to map wisp domain')
129
129
+
return true
130
130
+
} catch (err) {
131
131
+
console.error('Map wisp domain error:', err)
132
132
+
throw err
133
133
+
}
134
134
+
}
135
135
+
136
136
+
const mapCustomDomain = async (domainId: string, siteRkey: string | null) => {
137
137
+
try {
138
138
+
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
139
139
+
method: 'POST',
140
140
+
headers: { 'Content-Type': 'application/json' },
141
141
+
body: JSON.stringify({ siteRkey })
142
142
+
})
143
143
+
const data = await response.json()
144
144
+
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
145
145
+
return true
146
146
+
} catch (err) {
147
147
+
console.error('Map custom domain error:', err)
148
148
+
throw err
149
149
+
}
150
150
+
}
151
151
+
152
152
+
const claimWispDomain = async (handle: string) => {
153
153
+
try {
154
154
+
const response = await fetch('/api/domain/claim', {
155
155
+
method: 'POST',
156
156
+
headers: { 'Content-Type': 'application/json' },
157
157
+
body: JSON.stringify({ handle })
158
158
+
})
159
159
+
160
160
+
const data = await response.json()
161
161
+
if (data.success) {
162
162
+
await fetchDomains()
163
163
+
return { success: true }
164
164
+
} else {
165
165
+
throw new Error(data.error || 'Failed to claim domain')
166
166
+
}
167
167
+
} catch (err) {
168
168
+
console.error('Claim domain error:', err)
169
169
+
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
170
170
+
171
171
+
// Handle "Already claimed" error more gracefully
172
172
+
if (errorMessage.includes('Already claimed')) {
173
173
+
alert('You have already claimed a wisp.place subdomain. Please refresh the page.')
174
174
+
await fetchDomains()
175
175
+
} else {
176
176
+
alert(`Failed to claim domain: ${errorMessage}`)
177
177
+
}
178
178
+
return { success: false, error: errorMessage }
179
179
+
}
180
180
+
}
181
181
+
182
182
+
const checkWispAvailability = async (handle: string) => {
183
183
+
const trimmedHandle = handle.trim().toLowerCase()
184
184
+
if (!trimmedHandle) {
185
185
+
return { available: null }
186
186
+
}
187
187
+
188
188
+
try {
189
189
+
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
190
190
+
const data = await response.json()
191
191
+
return { available: data.available }
192
192
+
} catch (err) {
193
193
+
console.error('Check availability error:', err)
194
194
+
return { available: false }
195
195
+
}
196
196
+
}
197
197
+
198
198
+
return {
199
199
+
wispDomain,
200
200
+
customDomains,
201
201
+
domainsLoading,
202
202
+
verificationStatus,
203
203
+
fetchDomains,
204
204
+
addCustomDomain,
205
205
+
verifyDomain,
206
206
+
deleteCustomDomain,
207
207
+
mapWispDomain,
208
208
+
mapCustomDomain,
209
209
+
claimWispDomain,
210
210
+
checkWispAvailability
211
211
+
}
212
212
+
}
+112
public/editor/hooks/useSiteData.ts
···
1
1
+
import { useState } from 'react'
2
2
+
3
3
+
export interface Site {
4
4
+
did: string
5
5
+
rkey: string
6
6
+
display_name: string | null
7
7
+
created_at: number
8
8
+
updated_at: number
9
9
+
}
10
10
+
11
11
+
export interface DomainInfo {
12
12
+
type: 'wisp' | 'custom'
13
13
+
domain: string
14
14
+
verified?: boolean
15
15
+
id?: string
16
16
+
}
17
17
+
18
18
+
export interface SiteWithDomains extends Site {
19
19
+
domains?: DomainInfo[]
20
20
+
}
21
21
+
22
22
+
export function useSiteData() {
23
23
+
const [sites, setSites] = useState<SiteWithDomains[]>([])
24
24
+
const [sitesLoading, setSitesLoading] = useState(true)
25
25
+
const [isSyncing, setIsSyncing] = useState(false)
26
26
+
27
27
+
const fetchSites = async () => {
28
28
+
try {
29
29
+
const response = await fetch('/api/user/sites')
30
30
+
const data = await response.json()
31
31
+
const sitesData: Site[] = data.sites || []
32
32
+
33
33
+
// Fetch domain info for each site
34
34
+
const sitesWithDomains = await Promise.all(
35
35
+
sitesData.map(async (site) => {
36
36
+
try {
37
37
+
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
38
38
+
const domainsData = await domainsResponse.json()
39
39
+
return {
40
40
+
...site,
41
41
+
domains: domainsData.domains || []
42
42
+
}
43
43
+
} catch (err) {
44
44
+
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
45
45
+
return {
46
46
+
...site,
47
47
+
domains: []
48
48
+
}
49
49
+
}
50
50
+
})
51
51
+
)
52
52
+
53
53
+
setSites(sitesWithDomains)
54
54
+
} catch (err) {
55
55
+
console.error('Failed to fetch sites:', err)
56
56
+
} finally {
57
57
+
setSitesLoading(false)
58
58
+
}
59
59
+
}
60
60
+
61
61
+
const syncSites = async () => {
62
62
+
setIsSyncing(true)
63
63
+
try {
64
64
+
const response = await fetch('/api/user/sync', {
65
65
+
method: 'POST'
66
66
+
})
67
67
+
const data = await response.json()
68
68
+
if (data.success) {
69
69
+
console.log(`Synced ${data.synced} sites from PDS`)
70
70
+
// Refresh sites list
71
71
+
await fetchSites()
72
72
+
}
73
73
+
} catch (err) {
74
74
+
console.error('Failed to sync sites:', err)
75
75
+
alert('Failed to sync sites from PDS')
76
76
+
} finally {
77
77
+
setIsSyncing(false)
78
78
+
}
79
79
+
}
80
80
+
81
81
+
const deleteSite = async (rkey: string) => {
82
82
+
try {
83
83
+
const response = await fetch(`/api/site/${rkey}`, {
84
84
+
method: 'DELETE'
85
85
+
})
86
86
+
87
87
+
const data = await response.json()
88
88
+
if (data.success) {
89
89
+
// Refresh sites list
90
90
+
await fetchSites()
91
91
+
return true
92
92
+
} else {
93
93
+
throw new Error(data.error || 'Failed to delete site')
94
94
+
}
95
95
+
} catch (err) {
96
96
+
console.error('Delete site error:', err)
97
97
+
alert(
98
98
+
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
99
99
+
)
100
100
+
return false
101
101
+
}
102
102
+
}
103
103
+
104
104
+
return {
105
105
+
sites,
106
106
+
sitesLoading,
107
107
+
isSyncing,
108
108
+
fetchSites,
109
109
+
syncSites,
110
110
+
deleteSite
111
111
+
}
112
112
+
}
+29
public/editor/hooks/useUserInfo.ts
···
1
1
+
import { useState } from 'react'
2
2
+
3
3
+
export interface UserInfo {
4
4
+
did: string
5
5
+
handle: string
6
6
+
}
7
7
+
8
8
+
export function useUserInfo() {
9
9
+
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
10
10
+
const [loading, setLoading] = useState(true)
11
11
+
12
12
+
const fetchUserInfo = async () => {
13
13
+
try {
14
14
+
const response = await fetch('/api/user/info')
15
15
+
const data = await response.json()
16
16
+
setUserInfo(data)
17
17
+
} catch (err) {
18
18
+
console.error('Failed to fetch user info:', err)
19
19
+
} finally {
20
20
+
setLoading(false)
21
21
+
}
22
22
+
}
23
23
+
24
24
+
return {
25
25
+
userInfo,
26
26
+
loading,
27
27
+
fetchUserInfo
28
28
+
}
29
29
+
}
+18
public/editor/index.html
···
5
5
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
<title>Elysia Static</title>
7
7
<link rel="icon" type="image/x-icon" href="../favicon.ico">
8
8
+
<style>
9
9
+
/* Dark theme fallback styles for before JS loads */
10
10
+
@media (prefers-color-scheme: dark) {
11
11
+
body {
12
12
+
background-color: oklch(0.23 0.015 285);
13
13
+
color: oklch(0.90 0.005 285);
14
14
+
}
15
15
+
16
16
+
pre {
17
17
+
background-color: oklch(0.33 0.015 285) !important;
18
18
+
color: oklch(0.90 0.005 285) !important;
19
19
+
}
20
20
+
21
21
+
.bg-muted {
22
22
+
background-color: oklch(0.33 0.015 285) !important;
23
23
+
}
24
24
+
}
25
25
+
</style>
8
26
</head>
9
27
<body>
10
28
<div id="elysia"></div>
+258
public/editor/tabs/CLITab.tsx
···
1
1
+
import {
2
2
+
Card,
3
3
+
CardContent,
4
4
+
CardDescription,
5
5
+
CardHeader,
6
6
+
CardTitle
7
7
+
} from '@public/components/ui/card'
8
8
+
import { Badge } from '@public/components/ui/badge'
9
9
+
import { ExternalLink } from 'lucide-react'
10
10
+
import { CodeBlock } from '@public/components/ui/code-block'
11
11
+
12
12
+
export function CLITab() {
13
13
+
return (
14
14
+
<div className="space-y-4 min-h-[400px]">
15
15
+
<Card>
16
16
+
<CardHeader>
17
17
+
<div className="flex items-center gap-2 mb-2">
18
18
+
<CardTitle>Wisp CLI Tool</CardTitle>
19
19
+
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
20
20
+
<Badge variant="outline" className="text-xs">Alpha</Badge>
21
21
+
</div>
22
22
+
<CardDescription>
23
23
+
Deploy static sites directly from your terminal
24
24
+
</CardDescription>
25
25
+
</CardHeader>
26
26
+
<CardContent className="space-y-6">
27
27
+
<div className="prose prose-sm max-w-none dark:prose-invert">
28
28
+
<p className="text-sm text-muted-foreground">
29
29
+
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
30
30
+
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
31
31
+
</p>
32
32
+
</div>
33
33
+
34
34
+
<div className="space-y-3">
35
35
+
<h3 className="text-sm font-semibold">Download CLI</h3>
36
36
+
<div className="grid gap-2">
37
37
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
38
38
+
<a
39
39
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
40
40
+
target="_blank"
41
41
+
rel="noopener noreferrer"
42
42
+
className="flex items-center justify-between mb-2"
43
43
+
>
44
44
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
45
45
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
46
46
+
</a>
47
47
+
<div className="text-xs text-muted-foreground">
48
48
+
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
49
49
+
</div>
50
50
+
</div>
51
51
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
52
52
+
<a
53
53
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
54
54
+
target="_blank"
55
55
+
rel="noopener noreferrer"
56
56
+
className="flex items-center justify-between mb-2"
57
57
+
>
58
58
+
<span className="font-mono text-sm">Linux (ARM64)</span>
59
59
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
60
60
+
</a>
61
61
+
<div className="text-xs text-muted-foreground">
62
62
+
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
63
63
+
</div>
64
64
+
</div>
65
65
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
66
66
+
<a
67
67
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
68
68
+
target="_blank"
69
69
+
rel="noopener noreferrer"
70
70
+
className="flex items-center justify-between mb-2"
71
71
+
>
72
72
+
<span className="font-mono text-sm">Linux (x86_64)</span>
73
73
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
74
74
+
</a>
75
75
+
<div className="text-xs text-muted-foreground">
76
76
+
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
77
77
+
</div>
78
78
+
</div>
79
79
+
</div>
80
80
+
</div>
81
81
+
82
82
+
<div className="space-y-3">
83
83
+
<h3 className="text-sm font-semibold">Basic Usage</h3>
84
84
+
<CodeBlock
85
85
+
code={`# Download and make executable
86
86
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
87
87
+
chmod +x wisp-cli-macos-arm64
88
88
+
89
89
+
# Deploy your site (will use OAuth)
90
90
+
./wisp-cli-macos-arm64 your-handle.bsky.social \\
91
91
+
--path ./dist \\
92
92
+
--site my-site
93
93
+
94
94
+
# Your site will be available at:
95
95
+
# https://sites.wisp.place/your-handle/my-site`}
96
96
+
language="bash"
97
97
+
/>
98
98
+
</div>
99
99
+
100
100
+
<div className="space-y-3">
101
101
+
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
102
102
+
<p className="text-xs text-muted-foreground">
103
103
+
Deploy automatically on every push using{' '}
104
104
+
<a
105
105
+
href="https://blog.tangled.org/ci"
106
106
+
target="_blank"
107
107
+
rel="noopener noreferrer"
108
108
+
className="text-accent hover:underline"
109
109
+
>
110
110
+
Tangled Spindle
111
111
+
</a>
112
112
+
</p>
113
113
+
114
114
+
<div className="space-y-4">
115
115
+
<div>
116
116
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
117
117
+
<span>Example 1: Simple Asset Publishing</span>
118
118
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
119
119
+
</h4>
120
120
+
<CodeBlock
121
121
+
code={`when:
122
122
+
- event: ['push']
123
123
+
branch: ['main']
124
124
+
- event: ['manual']
125
125
+
126
126
+
engine: 'nixery'
127
127
+
128
128
+
clone:
129
129
+
skip: false
130
130
+
depth: 1
131
131
+
132
132
+
dependencies:
133
133
+
nixpkgs:
134
134
+
- coreutils
135
135
+
- curl
136
136
+
137
137
+
environment:
138
138
+
SITE_PATH: '.' # Copy entire repo
139
139
+
SITE_NAME: 'myWebbedSite'
140
140
+
WISP_HANDLE: 'your-handle.bsky.social'
141
141
+
142
142
+
steps:
143
143
+
- name: deploy assets to wisp
144
144
+
command: |
145
145
+
# Download Wisp CLI
146
146
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
147
147
+
chmod +x wisp-cli
148
148
+
149
149
+
# Deploy to Wisp
150
150
+
./wisp-cli \\
151
151
+
"$WISP_HANDLE" \\
152
152
+
--path "$SITE_PATH" \\
153
153
+
--site "$SITE_NAME" \\
154
154
+
--password "$WISP_APP_PASSWORD"
155
155
+
156
156
+
# Output
157
157
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
158
158
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
159
159
+
`}
160
160
+
language="yaml"
161
161
+
/>
162
162
+
</div>
163
163
+
164
164
+
<div>
165
165
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
166
166
+
<span>Example 2: React/Vite Build & Deploy</span>
167
167
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
168
168
+
</h4>
169
169
+
<CodeBlock
170
170
+
code={`when:
171
171
+
- event: ['push']
172
172
+
branch: ['main']
173
173
+
- event: ['manual']
174
174
+
175
175
+
engine: 'nixery'
176
176
+
177
177
+
clone:
178
178
+
skip: false
179
179
+
depth: 1
180
180
+
submodules: false
181
181
+
182
182
+
dependencies:
183
183
+
nixpkgs:
184
184
+
- nodejs
185
185
+
- coreutils
186
186
+
- curl
187
187
+
github:NixOS/nixpkgs/nixpkgs-unstable:
188
188
+
- bun
189
189
+
190
190
+
environment:
191
191
+
SITE_PATH: 'dist'
192
192
+
SITE_NAME: 'my-react-site'
193
193
+
WISP_HANDLE: 'your-handle.bsky.social'
194
194
+
195
195
+
steps:
196
196
+
- name: build site
197
197
+
command: |
198
198
+
# necessary to ensure bun is in PATH
199
199
+
export PATH="$HOME/.nix-profile/bin:$PATH"
200
200
+
201
201
+
bun install --frozen-lockfile
202
202
+
203
203
+
# build with vite, run directly to get around env issues
204
204
+
bun node_modules/.bin/vite build
205
205
+
206
206
+
- name: deploy to wisp
207
207
+
command: |
208
208
+
# Download Wisp CLI
209
209
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
210
210
+
chmod +x wisp-cli
211
211
+
212
212
+
# Deploy to Wisp
213
213
+
./wisp-cli \\
214
214
+
"$WISP_HANDLE" \\
215
215
+
--path "$SITE_PATH" \\
216
216
+
--site "$SITE_NAME" \\
217
217
+
--password "$WISP_APP_PASSWORD"`}
218
218
+
language="yaml"
219
219
+
/>
220
220
+
</div>
221
221
+
</div>
222
222
+
223
223
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
224
224
+
<p className="text-xs text-muted-foreground">
225
225
+
<strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings.
226
226
+
Generate an app password from your AT Protocol account settings.
227
227
+
</p>
228
228
+
</div>
229
229
+
</div>
230
230
+
231
231
+
<div className="space-y-3">
232
232
+
<h3 className="text-sm font-semibold">Learn More</h3>
233
233
+
<div className="grid gap-2">
234
234
+
<a
235
235
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
236
236
+
target="_blank"
237
237
+
rel="noopener noreferrer"
238
238
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
239
239
+
>
240
240
+
<span className="text-sm">Source Code</span>
241
241
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
242
242
+
</a>
243
243
+
<a
244
244
+
href="https://blog.tangled.org/ci"
245
245
+
target="_blank"
246
246
+
rel="noopener noreferrer"
247
247
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
248
248
+
>
249
249
+
<span className="text-sm">Tangled Spindle CI/CD</span>
250
250
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
251
251
+
</a>
252
252
+
</div>
253
253
+
</div>
254
254
+
</CardContent>
255
255
+
</Card>
256
256
+
</div>
257
257
+
)
258
258
+
}
+499
public/editor/tabs/DomainsTab.tsx
···
1
1
+
import { useState } from 'react'
2
2
+
import {
3
3
+
Card,
4
4
+
CardContent,
5
5
+
CardDescription,
6
6
+
CardHeader,
7
7
+
CardTitle
8
8
+
} from '@public/components/ui/card'
9
9
+
import { Button } from '@public/components/ui/button'
10
10
+
import { Input } from '@public/components/ui/input'
11
11
+
import { Label } from '@public/components/ui/label'
12
12
+
import { Badge } from '@public/components/ui/badge'
13
13
+
import {
14
14
+
Dialog,
15
15
+
DialogContent,
16
16
+
DialogDescription,
17
17
+
DialogHeader,
18
18
+
DialogTitle,
19
19
+
DialogFooter
20
20
+
} from '@public/components/ui/dialog'
21
21
+
import {
22
22
+
CheckCircle2,
23
23
+
XCircle,
24
24
+
Loader2,
25
25
+
Trash2
26
26
+
} from 'lucide-react'
27
27
+
import type { WispDomain, CustomDomain } from '../hooks/useDomainData'
28
28
+
import type { UserInfo } from '../hooks/useUserInfo'
29
29
+
30
30
+
interface DomainsTabProps {
31
31
+
wispDomain: WispDomain | null
32
32
+
customDomains: CustomDomain[]
33
33
+
domainsLoading: boolean
34
34
+
verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' }
35
35
+
userInfo: UserInfo | null
36
36
+
onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }>
37
37
+
onVerifyDomain: (id: string) => Promise<void>
38
38
+
onDeleteCustomDomain: (id: string) => Promise<boolean>
39
39
+
onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }>
40
40
+
onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }>
41
41
+
}
42
42
+
43
43
+
export function DomainsTab({
44
44
+
wispDomain,
45
45
+
customDomains,
46
46
+
domainsLoading,
47
47
+
verificationStatus,
48
48
+
userInfo,
49
49
+
onAddCustomDomain,
50
50
+
onVerifyDomain,
51
51
+
onDeleteCustomDomain,
52
52
+
onClaimWispDomain,
53
53
+
onCheckWispAvailability
54
54
+
}: DomainsTabProps) {
55
55
+
// Wisp domain claim state
56
56
+
const [wispHandle, setWispHandle] = useState('')
57
57
+
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
58
58
+
const [wispAvailability, setWispAvailability] = useState<{
59
59
+
available: boolean | null
60
60
+
checking: boolean
61
61
+
}>({ available: null, checking: false })
62
62
+
63
63
+
// Custom domain modal state
64
64
+
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
65
65
+
const [customDomain, setCustomDomain] = useState('')
66
66
+
const [isAddingDomain, setIsAddingDomain] = useState(false)
67
67
+
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
68
68
+
69
69
+
const checkWispAvailability = async (handle: string) => {
70
70
+
const trimmedHandle = handle.trim().toLowerCase()
71
71
+
if (!trimmedHandle) {
72
72
+
setWispAvailability({ available: null, checking: false })
73
73
+
return
74
74
+
}
75
75
+
76
76
+
setWispAvailability({ available: null, checking: true })
77
77
+
const result = await onCheckWispAvailability(trimmedHandle)
78
78
+
setWispAvailability({ available: result.available, checking: false })
79
79
+
}
80
80
+
81
81
+
const handleClaimWispDomain = async () => {
82
82
+
const trimmedHandle = wispHandle.trim().toLowerCase()
83
83
+
if (!trimmedHandle) {
84
84
+
alert('Please enter a handle')
85
85
+
return
86
86
+
}
87
87
+
88
88
+
setIsClaimingWisp(true)
89
89
+
const result = await onClaimWispDomain(trimmedHandle)
90
90
+
if (result.success) {
91
91
+
setWispHandle('')
92
92
+
setWispAvailability({ available: null, checking: false })
93
93
+
}
94
94
+
setIsClaimingWisp(false)
95
95
+
}
96
96
+
97
97
+
const handleAddCustomDomain = async () => {
98
98
+
if (!customDomain) {
99
99
+
alert('Please enter a domain')
100
100
+
return
101
101
+
}
102
102
+
103
103
+
setIsAddingDomain(true)
104
104
+
const result = await onAddCustomDomain(customDomain)
105
105
+
setIsAddingDomain(false)
106
106
+
107
107
+
if (result.success) {
108
108
+
setCustomDomain('')
109
109
+
setAddDomainModalOpen(false)
110
110
+
// Automatically show DNS configuration for the newly added domain
111
111
+
if (result.id) {
112
112
+
setViewDomainDNS(result.id)
113
113
+
}
114
114
+
}
115
115
+
}
116
116
+
117
117
+
return (
118
118
+
<>
119
119
+
<div className="space-y-4 min-h-[400px]">
120
120
+
<Card>
121
121
+
<CardHeader>
122
122
+
<CardTitle>wisp.place Subdomain</CardTitle>
123
123
+
<CardDescription>
124
124
+
Your free subdomain on the wisp.place network
125
125
+
</CardDescription>
126
126
+
</CardHeader>
127
127
+
<CardContent>
128
128
+
{domainsLoading ? (
129
129
+
<div className="flex items-center justify-center py-4">
130
130
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
131
131
+
</div>
132
132
+
) : wispDomain ? (
133
133
+
<>
134
134
+
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
135
135
+
<div className="flex items-center gap-2">
136
136
+
<CheckCircle2 className="w-5 h-5 text-green-500" />
137
137
+
<span className="font-mono text-lg">
138
138
+
{wispDomain.domain}
139
139
+
</span>
140
140
+
</div>
141
141
+
{wispDomain.rkey && (
142
142
+
<p className="text-xs text-muted-foreground ml-7">
143
143
+
→ Mapped to site: {wispDomain.rkey}
144
144
+
</p>
145
145
+
)}
146
146
+
</div>
147
147
+
<p className="text-sm text-muted-foreground mt-3">
148
148
+
{wispDomain.rkey
149
149
+
? 'This domain is mapped to a specific site'
150
150
+
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
151
151
+
</p>
152
152
+
</>
153
153
+
) : (
154
154
+
<div className="space-y-4">
155
155
+
<div className="p-4 bg-muted/30 rounded-lg">
156
156
+
<p className="text-sm text-muted-foreground mb-4">
157
157
+
Claim your free wisp.place subdomain
158
158
+
</p>
159
159
+
<div className="space-y-3">
160
160
+
<div className="space-y-2">
161
161
+
<Label htmlFor="wisp-handle">Choose your handle</Label>
162
162
+
<div className="flex gap-2">
163
163
+
<div className="flex-1 relative">
164
164
+
<Input
165
165
+
id="wisp-handle"
166
166
+
placeholder="mysite"
167
167
+
value={wispHandle}
168
168
+
onChange={(e) => {
169
169
+
setWispHandle(e.target.value)
170
170
+
if (e.target.value.trim()) {
171
171
+
checkWispAvailability(e.target.value)
172
172
+
} else {
173
173
+
setWispAvailability({ available: null, checking: false })
174
174
+
}
175
175
+
}}
176
176
+
disabled={isClaimingWisp}
177
177
+
className="pr-24"
178
178
+
/>
179
179
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
180
180
+
.wisp.place
181
181
+
</span>
182
182
+
</div>
183
183
+
</div>
184
184
+
{wispAvailability.checking && (
185
185
+
<p className="text-xs text-muted-foreground flex items-center gap-1">
186
186
+
<Loader2 className="w-3 h-3 animate-spin" />
187
187
+
Checking availability...
188
188
+
</p>
189
189
+
)}
190
190
+
{!wispAvailability.checking && wispAvailability.available === true && (
191
191
+
<p className="text-xs text-green-600 flex items-center gap-1">
192
192
+
<CheckCircle2 className="w-3 h-3" />
193
193
+
Available
194
194
+
</p>
195
195
+
)}
196
196
+
{!wispAvailability.checking && wispAvailability.available === false && (
197
197
+
<p className="text-xs text-red-600 flex items-center gap-1">
198
198
+
<XCircle className="w-3 h-3" />
199
199
+
Not available
200
200
+
</p>
201
201
+
)}
202
202
+
</div>
203
203
+
<Button
204
204
+
onClick={handleClaimWispDomain}
205
205
+
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
206
206
+
className="w-full"
207
207
+
>
208
208
+
{isClaimingWisp ? (
209
209
+
<>
210
210
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
211
211
+
Claiming...
212
212
+
</>
213
213
+
) : (
214
214
+
'Claim Subdomain'
215
215
+
)}
216
216
+
</Button>
217
217
+
</div>
218
218
+
</div>
219
219
+
</div>
220
220
+
)}
221
221
+
</CardContent>
222
222
+
</Card>
223
223
+
224
224
+
<Card>
225
225
+
<CardHeader>
226
226
+
<CardTitle>Custom Domains</CardTitle>
227
227
+
<CardDescription>
228
228
+
Bring your own domain with DNS verification
229
229
+
</CardDescription>
230
230
+
</CardHeader>
231
231
+
<CardContent className="space-y-4">
232
232
+
<Button
233
233
+
onClick={() => setAddDomainModalOpen(true)}
234
234
+
className="w-full"
235
235
+
>
236
236
+
Add Custom Domain
237
237
+
</Button>
238
238
+
239
239
+
{domainsLoading ? (
240
240
+
<div className="flex items-center justify-center py-4">
241
241
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
242
242
+
</div>
243
243
+
) : customDomains.length === 0 ? (
244
244
+
<div className="text-center py-4 text-muted-foreground text-sm">
245
245
+
No custom domains added yet
246
246
+
</div>
247
247
+
) : (
248
248
+
<div className="space-y-2">
249
249
+
{customDomains.map((domain) => (
250
250
+
<div
251
251
+
key={domain.id}
252
252
+
className="flex items-center justify-between p-3 border border-border rounded-lg"
253
253
+
>
254
254
+
<div className="flex flex-col gap-1 flex-1">
255
255
+
<div className="flex items-center gap-2">
256
256
+
{domain.verified ? (
257
257
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
258
258
+
) : (
259
259
+
<XCircle className="w-4 h-4 text-red-500" />
260
260
+
)}
261
261
+
<span className="font-mono">
262
262
+
{domain.domain}
263
263
+
</span>
264
264
+
</div>
265
265
+
{domain.rkey && domain.rkey !== 'self' && (
266
266
+
<p className="text-xs text-muted-foreground ml-6">
267
267
+
→ Mapped to site: {domain.rkey}
268
268
+
</p>
269
269
+
)}
270
270
+
</div>
271
271
+
<div className="flex items-center gap-2">
272
272
+
<Button
273
273
+
variant="outline"
274
274
+
size="sm"
275
275
+
onClick={() =>
276
276
+
setViewDomainDNS(domain.id)
277
277
+
}
278
278
+
>
279
279
+
View DNS
280
280
+
</Button>
281
281
+
{domain.verified ? (
282
282
+
<Badge variant="secondary">
283
283
+
Verified
284
284
+
</Badge>
285
285
+
) : (
286
286
+
<Button
287
287
+
variant="outline"
288
288
+
size="sm"
289
289
+
onClick={() =>
290
290
+
onVerifyDomain(domain.id)
291
291
+
}
292
292
+
disabled={
293
293
+
verificationStatus[
294
294
+
domain.id
295
295
+
] === 'verifying'
296
296
+
}
297
297
+
>
298
298
+
{verificationStatus[
299
299
+
domain.id
300
300
+
] === 'verifying' ? (
301
301
+
<>
302
302
+
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
303
303
+
Verifying...
304
304
+
</>
305
305
+
) : (
306
306
+
'Verify DNS'
307
307
+
)}
308
308
+
</Button>
309
309
+
)}
310
310
+
<Button
311
311
+
variant="ghost"
312
312
+
size="sm"
313
313
+
onClick={() =>
314
314
+
onDeleteCustomDomain(
315
315
+
domain.id
316
316
+
)
317
317
+
}
318
318
+
>
319
319
+
<Trash2 className="w-4 h-4" />
320
320
+
</Button>
321
321
+
</div>
322
322
+
</div>
323
323
+
))}
324
324
+
</div>
325
325
+
)}
326
326
+
</CardContent>
327
327
+
</Card>
328
328
+
</div>
329
329
+
330
330
+
{/* Add Custom Domain Modal */}
331
331
+
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
332
332
+
<DialogContent className="sm:max-w-lg">
333
333
+
<DialogHeader>
334
334
+
<DialogTitle>Add Custom Domain</DialogTitle>
335
335
+
<DialogDescription>
336
336
+
Enter your domain name. After adding, you'll see the DNS
337
337
+
records to configure.
338
338
+
</DialogDescription>
339
339
+
</DialogHeader>
340
340
+
<div className="space-y-4 py-4">
341
341
+
<div className="space-y-2">
342
342
+
<Label htmlFor="new-domain">Domain Name</Label>
343
343
+
<Input
344
344
+
id="new-domain"
345
345
+
placeholder="example.com"
346
346
+
value={customDomain}
347
347
+
onChange={(e) => setCustomDomain(e.target.value)}
348
348
+
/>
349
349
+
<p className="text-xs text-muted-foreground">
350
350
+
After adding, click "View DNS" to see the records you
351
351
+
need to configure.
352
352
+
</p>
353
353
+
</div>
354
354
+
</div>
355
355
+
<DialogFooter className="flex-col sm:flex-row gap-2">
356
356
+
<Button
357
357
+
variant="outline"
358
358
+
onClick={() => {
359
359
+
setAddDomainModalOpen(false)
360
360
+
setCustomDomain('')
361
361
+
}}
362
362
+
className="w-full sm:w-auto"
363
363
+
disabled={isAddingDomain}
364
364
+
>
365
365
+
Cancel
366
366
+
</Button>
367
367
+
<Button
368
368
+
onClick={handleAddCustomDomain}
369
369
+
disabled={!customDomain || isAddingDomain}
370
370
+
className="w-full sm:w-auto"
371
371
+
>
372
372
+
{isAddingDomain ? (
373
373
+
<>
374
374
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
375
375
+
Adding...
376
376
+
</>
377
377
+
) : (
378
378
+
'Add Domain'
379
379
+
)}
380
380
+
</Button>
381
381
+
</DialogFooter>
382
382
+
</DialogContent>
383
383
+
</Dialog>
384
384
+
385
385
+
{/* View DNS Records Modal */}
386
386
+
<Dialog
387
387
+
open={viewDomainDNS !== null}
388
388
+
onOpenChange={(open) => !open && setViewDomainDNS(null)}
389
389
+
>
390
390
+
<DialogContent className="sm:max-w-lg">
391
391
+
<DialogHeader>
392
392
+
<DialogTitle>DNS Configuration</DialogTitle>
393
393
+
<DialogDescription>
394
394
+
Add these DNS records to your domain provider
395
395
+
</DialogDescription>
396
396
+
</DialogHeader>
397
397
+
{viewDomainDNS && userInfo && (
398
398
+
<>
399
399
+
{(() => {
400
400
+
const domain = customDomains.find(
401
401
+
(d) => d.id === viewDomainDNS
402
402
+
)
403
403
+
if (!domain) return null
404
404
+
405
405
+
return (
406
406
+
<div className="space-y-4 py-4">
407
407
+
<div className="p-3 bg-muted/30 rounded-lg">
408
408
+
<p className="text-sm font-medium mb-1">
409
409
+
Domain:
410
410
+
</p>
411
411
+
<p className="font-mono text-sm">
412
412
+
{domain.domain}
413
413
+
</p>
414
414
+
</div>
415
415
+
416
416
+
<div className="space-y-3">
417
417
+
<div className="p-3 bg-background rounded border border-border">
418
418
+
<div className="flex justify-between items-start mb-2">
419
419
+
<span className="text-xs font-semibold text-muted-foreground">
420
420
+
TXT Record (Verification)
421
421
+
</span>
422
422
+
</div>
423
423
+
<div className="font-mono text-xs space-y-2">
424
424
+
<div>
425
425
+
<span className="text-muted-foreground">
426
426
+
Name:
427
427
+
</span>{' '}
428
428
+
<span className="select-all">
429
429
+
_wisp.{domain.domain}
430
430
+
</span>
431
431
+
</div>
432
432
+
<div>
433
433
+
<span className="text-muted-foreground">
434
434
+
Value:
435
435
+
</span>{' '}
436
436
+
<span className="select-all break-all">
437
437
+
{userInfo.did}
438
438
+
</span>
439
439
+
</div>
440
440
+
</div>
441
441
+
</div>
442
442
+
443
443
+
<div className="p-3 bg-background rounded border border-border">
444
444
+
<div className="flex justify-between items-start mb-2">
445
445
+
<span className="text-xs font-semibold text-muted-foreground">
446
446
+
CNAME Record (Pointing)
447
447
+
</span>
448
448
+
</div>
449
449
+
<div className="font-mono text-xs space-y-2">
450
450
+
<div>
451
451
+
<span className="text-muted-foreground">
452
452
+
Name:
453
453
+
</span>{' '}
454
454
+
<span className="select-all">
455
455
+
{domain.domain}
456
456
+
</span>
457
457
+
</div>
458
458
+
<div>
459
459
+
<span className="text-muted-foreground">
460
460
+
Value:
461
461
+
</span>{' '}
462
462
+
<span className="select-all">
463
463
+
{domain.id}.dns.wisp.place
464
464
+
</span>
465
465
+
</div>
466
466
+
</div>
467
467
+
<p className="text-xs text-muted-foreground mt-2">
468
468
+
Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification.
469
469
+
</p>
470
470
+
</div>
471
471
+
</div>
472
472
+
473
473
+
<div className="p-3 bg-muted/30 rounded-lg">
474
474
+
<p className="text-xs text-muted-foreground">
475
475
+
💡 After configuring DNS, click "Verify DNS"
476
476
+
to check if everything is set up correctly.
477
477
+
DNS changes can take a few minutes to
478
478
+
propagate.
479
479
+
</p>
480
480
+
</div>
481
481
+
</div>
482
482
+
)
483
483
+
})()}
484
484
+
</>
485
485
+
)}
486
486
+
<DialogFooter>
487
487
+
<Button
488
488
+
variant="outline"
489
489
+
onClick={() => setViewDomainDNS(null)}
490
490
+
className="w-full sm:w-auto"
491
491
+
>
492
492
+
Close
493
493
+
</Button>
494
494
+
</DialogFooter>
495
495
+
</DialogContent>
496
496
+
</Dialog>
497
497
+
</>
498
498
+
)
499
499
+
}
+196
public/editor/tabs/SitesTab.tsx
···
1
1
+
import {
2
2
+
Card,
3
3
+
CardContent,
4
4
+
CardDescription,
5
5
+
CardHeader,
6
6
+
CardTitle
7
7
+
} from '@public/components/ui/card'
8
8
+
import { Button } from '@public/components/ui/button'
9
9
+
import { Badge } from '@public/components/ui/badge'
10
10
+
import {
11
11
+
Globe,
12
12
+
ExternalLink,
13
13
+
CheckCircle2,
14
14
+
AlertCircle,
15
15
+
Loader2,
16
16
+
RefreshCw,
17
17
+
Settings
18
18
+
} from 'lucide-react'
19
19
+
import type { SiteWithDomains } from '../hooks/useSiteData'
20
20
+
import type { UserInfo } from '../hooks/useUserInfo'
21
21
+
22
22
+
interface SitesTabProps {
23
23
+
sites: SiteWithDomains[]
24
24
+
sitesLoading: boolean
25
25
+
isSyncing: boolean
26
26
+
userInfo: UserInfo | null
27
27
+
onSyncSites: () => Promise<void>
28
28
+
onConfigureSite: (site: SiteWithDomains) => void
29
29
+
}
30
30
+
31
31
+
export function SitesTab({
32
32
+
sites,
33
33
+
sitesLoading,
34
34
+
isSyncing,
35
35
+
userInfo,
36
36
+
onSyncSites,
37
37
+
onConfigureSite
38
38
+
}: SitesTabProps) {
39
39
+
const getSiteUrl = (site: SiteWithDomains) => {
40
40
+
// Use the first mapped domain if available
41
41
+
if (site.domains && site.domains.length > 0) {
42
42
+
return `https://${site.domains[0].domain}`
43
43
+
}
44
44
+
45
45
+
// Default fallback URL - use handle instead of DID
46
46
+
if (!userInfo) return '#'
47
47
+
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
48
48
+
}
49
49
+
50
50
+
const getSiteDomainName = (site: SiteWithDomains) => {
51
51
+
// Return the first domain if available
52
52
+
if (site.domains && site.domains.length > 0) {
53
53
+
return site.domains[0].domain
54
54
+
}
55
55
+
56
56
+
// Use handle instead of DID for display
57
57
+
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
58
58
+
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
59
59
+
}
60
60
+
61
61
+
return (
62
62
+
<div className="space-y-4 min-h-[400px]">
63
63
+
<Card>
64
64
+
<CardHeader>
65
65
+
<div className="flex items-center justify-between">
66
66
+
<div>
67
67
+
<CardTitle>Your Sites</CardTitle>
68
68
+
<CardDescription>
69
69
+
View and manage all your deployed sites
70
70
+
</CardDescription>
71
71
+
</div>
72
72
+
<Button
73
73
+
variant="outline"
74
74
+
size="sm"
75
75
+
onClick={onSyncSites}
76
76
+
disabled={isSyncing || sitesLoading}
77
77
+
>
78
78
+
<RefreshCw
79
79
+
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
80
80
+
/>
81
81
+
Sync from PDS
82
82
+
</Button>
83
83
+
</div>
84
84
+
</CardHeader>
85
85
+
<CardContent className="space-y-4">
86
86
+
{sitesLoading ? (
87
87
+
<div className="flex items-center justify-center py-8">
88
88
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
89
89
+
</div>
90
90
+
) : sites.length === 0 ? (
91
91
+
<div className="text-center py-8 text-muted-foreground">
92
92
+
<p>No sites yet. Upload your first site!</p>
93
93
+
</div>
94
94
+
) : (
95
95
+
sites.map((site) => (
96
96
+
<div
97
97
+
key={`${site.did}-${site.rkey}`}
98
98
+
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
99
99
+
>
100
100
+
<div className="flex-1">
101
101
+
<div className="flex items-center gap-3 mb-2">
102
102
+
<h3 className="font-semibold text-lg">
103
103
+
{site.display_name || site.rkey}
104
104
+
</h3>
105
105
+
<Badge
106
106
+
variant="secondary"
107
107
+
className="text-xs"
108
108
+
>
109
109
+
active
110
110
+
</Badge>
111
111
+
</div>
112
112
+
113
113
+
{/* Display all mapped domains */}
114
114
+
{site.domains && site.domains.length > 0 ? (
115
115
+
<div className="space-y-1">
116
116
+
{site.domains.map((domainInfo, idx) => (
117
117
+
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
118
118
+
<a
119
119
+
href={`https://${domainInfo.domain}`}
120
120
+
target="_blank"
121
121
+
rel="noopener noreferrer"
122
122
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
123
123
+
>
124
124
+
<Globe className="w-3 h-3" />
125
125
+
{domainInfo.domain}
126
126
+
<ExternalLink className="w-3 h-3" />
127
127
+
</a>
128
128
+
<Badge
129
129
+
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
130
130
+
className="text-xs"
131
131
+
>
132
132
+
{domainInfo.type}
133
133
+
</Badge>
134
134
+
{domainInfo.type === 'custom' && (
135
135
+
<Badge
136
136
+
variant={domainInfo.verified ? 'default' : 'secondary'}
137
137
+
className="text-xs"
138
138
+
>
139
139
+
{domainInfo.verified ? (
140
140
+
<>
141
141
+
<CheckCircle2 className="w-3 h-3 mr-1" />
142
142
+
verified
143
143
+
</>
144
144
+
) : (
145
145
+
<>
146
146
+
<AlertCircle className="w-3 h-3 mr-1" />
147
147
+
pending
148
148
+
</>
149
149
+
)}
150
150
+
</Badge>
151
151
+
)}
152
152
+
</div>
153
153
+
))}
154
154
+
</div>
155
155
+
) : (
156
156
+
<a
157
157
+
href={getSiteUrl(site)}
158
158
+
target="_blank"
159
159
+
rel="noopener noreferrer"
160
160
+
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
161
161
+
>
162
162
+
{getSiteDomainName(site)}
163
163
+
<ExternalLink className="w-3 h-3" />
164
164
+
</a>
165
165
+
)}
166
166
+
</div>
167
167
+
<Button
168
168
+
variant="outline"
169
169
+
size="sm"
170
170
+
onClick={() => onConfigureSite(site)}
171
171
+
>
172
172
+
<Settings className="w-4 h-4 mr-2" />
173
173
+
Configure
174
174
+
</Button>
175
175
+
</div>
176
176
+
))
177
177
+
)}
178
178
+
</CardContent>
179
179
+
</Card>
180
180
+
181
181
+
<div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
182
182
+
<div className="flex items-start gap-2">
183
183
+
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
184
184
+
<div className="flex-1 space-y-1">
185
185
+
<p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
186
186
+
Note about sites.wisp.place URLs
187
187
+
</p>
188
188
+
<p className="text-xs text-muted-foreground">
189
189
+
Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths.
190
190
+
</p>
191
191
+
</div>
192
192
+
</div>
193
193
+
</div>
194
194
+
</div>
195
195
+
)
196
196
+
}
+494
public/editor/tabs/UploadTab.tsx
···
1
1
+
import { useState, useEffect } from 'react'
2
2
+
import {
3
3
+
Card,
4
4
+
CardContent,
5
5
+
CardDescription,
6
6
+
CardHeader,
7
7
+
CardTitle
8
8
+
} from '@public/components/ui/card'
9
9
+
import { Button } from '@public/components/ui/button'
10
10
+
import { Input } from '@public/components/ui/input'
11
11
+
import { Label } from '@public/components/ui/label'
12
12
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
13
13
+
import { Badge } from '@public/components/ui/badge'
14
14
+
import {
15
15
+
Globe,
16
16
+
Upload,
17
17
+
AlertCircle,
18
18
+
Loader2
19
19
+
} from 'lucide-react'
20
20
+
import type { SiteWithDomains } from '../hooks/useSiteData'
21
21
+
22
22
+
interface UploadTabProps {
23
23
+
sites: SiteWithDomains[]
24
24
+
sitesLoading: boolean
25
25
+
onUploadComplete: () => Promise<void>
26
26
+
}
27
27
+
28
28
+
// Batching configuration
29
29
+
const BATCH_SIZE = 15 // files per batch
30
30
+
const CONCURRENT_BATCHES = 3 // parallel batches
31
31
+
const MAX_RETRIES = 2 // retry attempts per file
32
32
+
33
33
+
interface BatchProgress {
34
34
+
total: number
35
35
+
uploaded: number
36
36
+
failed: number
37
37
+
current: number
38
38
+
}
39
39
+
40
40
+
export function UploadTab({
41
41
+
sites,
42
42
+
sitesLoading,
43
43
+
onUploadComplete
44
44
+
}: UploadTabProps) {
45
45
+
// Upload state
46
46
+
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
47
47
+
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
48
48
+
const [newSiteName, setNewSiteName] = useState('')
49
49
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
50
50
+
const [isUploading, setIsUploading] = useState(false)
51
51
+
const [uploadProgress, setUploadProgress] = useState('')
52
52
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
53
53
+
const [uploadedCount, setUploadedCount] = useState(0)
54
54
+
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null)
55
55
+
56
56
+
// Auto-switch to 'new' mode if no sites exist
57
57
+
useEffect(() => {
58
58
+
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
59
59
+
setSiteMode('new')
60
60
+
}
61
61
+
}, [sites, sitesLoading, siteMode])
62
62
+
63
63
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
64
64
+
if (e.target.files && e.target.files.length > 0) {
65
65
+
setSelectedFiles(e.target.files)
66
66
+
}
67
67
+
}
68
68
+
69
69
+
// Split files into batches
70
70
+
const createBatches = (files: FileList): File[][] => {
71
71
+
const batches: File[][] = []
72
72
+
const fileArray = Array.from(files)
73
73
+
74
74
+
for (let i = 0; i < fileArray.length; i += BATCH_SIZE) {
75
75
+
batches.push(fileArray.slice(i, i + BATCH_SIZE))
76
76
+
}
77
77
+
78
78
+
return batches
79
79
+
}
80
80
+
81
81
+
// Upload a single file with retry logic
82
82
+
const uploadFileWithRetry = async (
83
83
+
file: File,
84
84
+
retries: number = MAX_RETRIES
85
85
+
): Promise<{ success: boolean; error?: string }> => {
86
86
+
for (let attempt = 0; attempt <= retries; attempt++) {
87
87
+
try {
88
88
+
// Simulate file validation (would normally happen on server)
89
89
+
// Return success (actual upload happens in batch)
90
90
+
return { success: true }
91
91
+
} catch (err) {
92
92
+
// Check if error is retryable
93
93
+
const error = err as any
94
94
+
const statusCode = error?.response?.status
95
95
+
96
96
+
// Don't retry for client errors (4xx except timeouts)
97
97
+
if (statusCode === 413 || statusCode === 400) {
98
98
+
return {
99
99
+
success: false,
100
100
+
error: statusCode === 413 ? 'File too large' : 'Validation error'
101
101
+
}
102
102
+
}
103
103
+
104
104
+
// If this was the last attempt, fail
105
105
+
if (attempt === retries) {
106
106
+
return {
107
107
+
success: false,
108
108
+
error: err instanceof Error ? err.message : 'Upload failed'
109
109
+
}
110
110
+
}
111
111
+
112
112
+
// Wait before retry (exponential backoff)
113
113
+
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)))
114
114
+
}
115
115
+
}
116
116
+
117
117
+
return { success: false, error: 'Max retries exceeded' }
118
118
+
}
119
119
+
120
120
+
// Process a single batch
121
121
+
const processBatch = async (
122
122
+
batch: File[],
123
123
+
batchIndex: number,
124
124
+
totalBatches: number,
125
125
+
formData: FormData
126
126
+
): Promise<{ succeeded: File[]; failed: Array<{ file: File; reason: string }> }> => {
127
127
+
const succeeded: File[] = []
128
128
+
const failed: Array<{ file: File; reason: string }> = []
129
129
+
130
130
+
setUploadProgress(`Processing batch ${batchIndex + 1}/${totalBatches} (files ${batchIndex * BATCH_SIZE + 1}-${Math.min((batchIndex + 1) * BATCH_SIZE, formData.getAll('files').length)})...`)
131
131
+
132
132
+
// Process files in batch with retry logic
133
133
+
const results = await Promise.allSettled(
134
134
+
batch.map(file => uploadFileWithRetry(file))
135
135
+
)
136
136
+
137
137
+
results.forEach((result, idx) => {
138
138
+
if (result.status === 'fulfilled' && result.value.success) {
139
139
+
succeeded.push(batch[idx])
140
140
+
} else {
141
141
+
const reason = result.status === 'rejected'
142
142
+
? 'Upload failed'
143
143
+
: result.value.error || 'Unknown error'
144
144
+
failed.push({ file: batch[idx], reason })
145
145
+
}
146
146
+
})
147
147
+
148
148
+
return { succeeded, failed }
149
149
+
}
150
150
+
151
151
+
// Main upload handler with batching
152
152
+
const handleUpload = async () => {
153
153
+
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
154
154
+
155
155
+
if (!siteName) {
156
156
+
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
157
157
+
return
158
158
+
}
159
159
+
160
160
+
if (!selectedFiles || selectedFiles.length === 0) {
161
161
+
alert('Please select files to upload')
162
162
+
return
163
163
+
}
164
164
+
165
165
+
setIsUploading(true)
166
166
+
setUploadProgress('Preparing files...')
167
167
+
setSkippedFiles([])
168
168
+
setUploadedCount(0)
169
169
+
170
170
+
try {
171
171
+
const formData = new FormData()
172
172
+
formData.append('siteName', siteName)
173
173
+
174
174
+
// Add all files to FormData
175
175
+
for (let i = 0; i < selectedFiles.length; i++) {
176
176
+
formData.append('files', selectedFiles[i])
177
177
+
}
178
178
+
179
179
+
const totalFiles = selectedFiles.length
180
180
+
const batches = createBatches(selectedFiles)
181
181
+
const totalBatches = batches.length
182
182
+
183
183
+
console.log(`Uploading ${totalFiles} files in ${totalBatches} batches (${BATCH_SIZE} files per batch, ${CONCURRENT_BATCHES} concurrent)`)
184
184
+
185
185
+
// Initialize batch progress
186
186
+
setBatchProgress({
187
187
+
total: totalFiles,
188
188
+
uploaded: 0,
189
189
+
failed: 0,
190
190
+
current: 0
191
191
+
})
192
192
+
193
193
+
// Process batches with concurrency limit
194
194
+
const allSkipped: Array<{ name: string; reason: string }> = []
195
195
+
let totalUploaded = 0
196
196
+
197
197
+
for (let i = 0; i < batches.length; i += CONCURRENT_BATCHES) {
198
198
+
const batchSlice = batches.slice(i, i + CONCURRENT_BATCHES)
199
199
+
const batchPromises = batchSlice.map((batch, idx) =>
200
200
+
processBatch(batch, i + idx, totalBatches, formData)
201
201
+
)
202
202
+
203
203
+
const results = await Promise.all(batchPromises)
204
204
+
205
205
+
// Aggregate results
206
206
+
results.forEach(result => {
207
207
+
totalUploaded += result.succeeded.length
208
208
+
result.failed.forEach(({ file, reason }) => {
209
209
+
allSkipped.push({ name: file.name, reason })
210
210
+
})
211
211
+
})
212
212
+
213
213
+
// Update progress
214
214
+
setBatchProgress({
215
215
+
total: totalFiles,
216
216
+
uploaded: totalUploaded,
217
217
+
failed: allSkipped.length,
218
218
+
current: Math.min((i + CONCURRENT_BATCHES) * BATCH_SIZE, totalFiles)
219
219
+
})
220
220
+
}
221
221
+
222
222
+
// Now send the actual upload request to the server
223
223
+
// (In a real implementation, you'd send batches to the server,
224
224
+
// but for compatibility with the existing API, we send all at once)
225
225
+
setUploadProgress('Finalizing upload to AT Protocol...')
226
226
+
227
227
+
const response = await fetch('/wisp/upload-files', {
228
228
+
method: 'POST',
229
229
+
body: formData
230
230
+
})
231
231
+
232
232
+
const data = await response.json()
233
233
+
if (data.success) {
234
234
+
setUploadProgress('Upload complete!')
235
235
+
setSkippedFiles(data.skippedFiles || allSkipped)
236
236
+
setUploadedCount(data.uploadedCount || data.fileCount || totalUploaded)
237
237
+
setSelectedSiteRkey('')
238
238
+
setNewSiteName('')
239
239
+
setSelectedFiles(null)
240
240
+
241
241
+
// Refresh sites list
242
242
+
await onUploadComplete()
243
243
+
244
244
+
// Reset form - give more time if there are skipped files
245
245
+
const resetDelay = (data.skippedFiles && data.skippedFiles.length > 0) || allSkipped.length > 0 ? 4000 : 1500
246
246
+
setTimeout(() => {
247
247
+
setUploadProgress('')
248
248
+
setSkippedFiles([])
249
249
+
setUploadedCount(0)
250
250
+
setBatchProgress(null)
251
251
+
setIsUploading(false)
252
252
+
}, resetDelay)
253
253
+
} else {
254
254
+
throw new Error(data.error || 'Upload failed')
255
255
+
}
256
256
+
} catch (err) {
257
257
+
console.error('Upload error:', err)
258
258
+
alert(
259
259
+
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
260
260
+
)
261
261
+
setIsUploading(false)
262
262
+
setUploadProgress('')
263
263
+
setBatchProgress(null)
264
264
+
}
265
265
+
}
266
266
+
267
267
+
return (
268
268
+
<div className="space-y-4 min-h-[400px]">
269
269
+
<Card>
270
270
+
<CardHeader>
271
271
+
<CardTitle>Upload Site</CardTitle>
272
272
+
<CardDescription>
273
273
+
Deploy a new site from a folder or Git repository
274
274
+
</CardDescription>
275
275
+
</CardHeader>
276
276
+
<CardContent className="space-y-6">
277
277
+
<div className="space-y-4">
278
278
+
<div className="p-4 bg-muted/50 rounded-lg">
279
279
+
<RadioGroup
280
280
+
value={siteMode}
281
281
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
282
282
+
disabled={isUploading}
283
283
+
>
284
284
+
<div className="flex items-center space-x-2">
285
285
+
<RadioGroupItem value="existing" id="existing" />
286
286
+
<Label htmlFor="existing" className="cursor-pointer">
287
287
+
Update existing site
288
288
+
</Label>
289
289
+
</div>
290
290
+
<div className="flex items-center space-x-2">
291
291
+
<RadioGroupItem value="new" id="new" />
292
292
+
<Label htmlFor="new" className="cursor-pointer">
293
293
+
Create new site
294
294
+
</Label>
295
295
+
</div>
296
296
+
</RadioGroup>
297
297
+
</div>
298
298
+
299
299
+
{siteMode === 'existing' ? (
300
300
+
<div className="space-y-2">
301
301
+
<Label htmlFor="site-select">Select Site</Label>
302
302
+
{sitesLoading ? (
303
303
+
<div className="flex items-center justify-center py-4">
304
304
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
305
305
+
</div>
306
306
+
) : sites.length === 0 ? (
307
307
+
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
308
308
+
No sites available. Create a new site instead.
309
309
+
</div>
310
310
+
) : (
311
311
+
<select
312
312
+
id="site-select"
313
313
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
314
314
+
value={selectedSiteRkey}
315
315
+
onChange={(e) => setSelectedSiteRkey(e.target.value)}
316
316
+
disabled={isUploading}
317
317
+
>
318
318
+
<option value="">Select a site...</option>
319
319
+
{sites.map((site) => (
320
320
+
<option key={site.rkey} value={site.rkey}>
321
321
+
{site.display_name || site.rkey}
322
322
+
</option>
323
323
+
))}
324
324
+
</select>
325
325
+
)}
326
326
+
</div>
327
327
+
) : (
328
328
+
<div className="space-y-2">
329
329
+
<Label htmlFor="new-site-name">New Site Name</Label>
330
330
+
<Input
331
331
+
id="new-site-name"
332
332
+
placeholder="my-awesome-site"
333
333
+
value={newSiteName}
334
334
+
onChange={(e) => setNewSiteName(e.target.value)}
335
335
+
disabled={isUploading}
336
336
+
/>
337
337
+
</div>
338
338
+
)}
339
339
+
340
340
+
<p className="text-xs text-muted-foreground">
341
341
+
File limits: 100MB per file, 300MB total
342
342
+
</p>
343
343
+
</div>
344
344
+
345
345
+
<div className="grid md:grid-cols-2 gap-4">
346
346
+
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
347
347
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
348
348
+
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
349
349
+
<h3 className="font-semibold mb-2">
350
350
+
Upload Folder
351
351
+
</h3>
352
352
+
<p className="text-sm text-muted-foreground mb-4">
353
353
+
Drag and drop or click to upload your
354
354
+
static site files
355
355
+
</p>
356
356
+
<input
357
357
+
type="file"
358
358
+
id="file-upload"
359
359
+
multiple
360
360
+
onChange={handleFileSelect}
361
361
+
className="hidden"
362
362
+
{...(({ webkitdirectory: '', directory: '' } as any))}
363
363
+
disabled={isUploading}
364
364
+
/>
365
365
+
<label htmlFor="file-upload">
366
366
+
<Button
367
367
+
variant="outline"
368
368
+
type="button"
369
369
+
onClick={() =>
370
370
+
document
371
371
+
.getElementById('file-upload')
372
372
+
?.click()
373
373
+
}
374
374
+
disabled={isUploading}
375
375
+
>
376
376
+
Choose Folder
377
377
+
</Button>
378
378
+
</label>
379
379
+
{selectedFiles && selectedFiles.length > 0 && (
380
380
+
<p className="text-sm text-muted-foreground mt-3">
381
381
+
{selectedFiles.length} files selected
382
382
+
</p>
383
383
+
)}
384
384
+
</CardContent>
385
385
+
</Card>
386
386
+
387
387
+
<Card className="border-2 border-dashed opacity-50">
388
388
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
389
389
+
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
390
390
+
<h3 className="font-semibold mb-2">
391
391
+
Connect Git Repository
392
392
+
</h3>
393
393
+
<p className="text-sm text-muted-foreground mb-4">
394
394
+
Link your GitHub, GitLab, or any Git
395
395
+
repository
396
396
+
</p>
397
397
+
<Badge variant="secondary">Coming soon!</Badge>
398
398
+
</CardContent>
399
399
+
</Card>
400
400
+
</div>
401
401
+
402
402
+
{uploadProgress && (
403
403
+
<div className="space-y-3">
404
404
+
<div className="p-4 bg-muted rounded-lg">
405
405
+
<div className="flex items-center gap-2 mb-2">
406
406
+
<Loader2 className="w-4 h-4 animate-spin" />
407
407
+
<span className="text-sm">{uploadProgress}</span>
408
408
+
</div>
409
409
+
{batchProgress && (
410
410
+
<div className="mt-2 space-y-1">
411
411
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
412
412
+
<span>
413
413
+
Uploaded: {batchProgress.uploaded}/{batchProgress.total}
414
414
+
</span>
415
415
+
<span>
416
416
+
Failed: {batchProgress.failed}
417
417
+
</span>
418
418
+
</div>
419
419
+
<div className="w-full bg-muted-foreground/20 rounded-full h-2">
420
420
+
<div
421
421
+
className="bg-accent h-2 rounded-full transition-all duration-300"
422
422
+
style={{
423
423
+
width: `${(batchProgress.uploaded / batchProgress.total) * 100}%`
424
424
+
}}
425
425
+
/>
426
426
+
</div>
427
427
+
</div>
428
428
+
)}
429
429
+
</div>
430
430
+
431
431
+
{skippedFiles.length > 0 && (
432
432
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
433
433
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
434
434
+
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
435
435
+
<div className="flex-1">
436
436
+
<span className="font-medium">
437
437
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
438
438
+
</span>
439
439
+
{uploadedCount > 0 && (
440
440
+
<span className="text-sm ml-2">
441
441
+
({uploadedCount} uploaded successfully)
442
442
+
</span>
443
443
+
)}
444
444
+
</div>
445
445
+
</div>
446
446
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
447
447
+
{skippedFiles.slice(0, 5).map((file, idx) => (
448
448
+
<div key={idx} className="text-xs">
449
449
+
<span className="font-mono">{file.name}</span>
450
450
+
<span className="text-muted-foreground"> - {file.reason}</span>
451
451
+
</div>
452
452
+
))}
453
453
+
{skippedFiles.length > 5 && (
454
454
+
<div className="text-xs text-muted-foreground">
455
455
+
...and {skippedFiles.length - 5} more
456
456
+
</div>
457
457
+
)}
458
458
+
</div>
459
459
+
</div>
460
460
+
)}
461
461
+
</div>
462
462
+
)}
463
463
+
464
464
+
<Button
465
465
+
onClick={handleUpload}
466
466
+
className="w-full"
467
467
+
disabled={
468
468
+
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
469
469
+
isUploading ||
470
470
+
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
471
471
+
}
472
472
+
>
473
473
+
{isUploading ? (
474
474
+
<>
475
475
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
476
476
+
Uploading...
477
477
+
</>
478
478
+
) : (
479
479
+
<>
480
480
+
{siteMode === 'existing' ? (
481
481
+
'Update Site'
482
482
+
) : (
483
483
+
selectedFiles && selectedFiles.length > 0
484
484
+
? 'Upload & Deploy'
485
485
+
: 'Create Empty Site'
486
486
+
)}
487
487
+
</>
488
488
+
)}
489
489
+
</Button>
490
490
+
</CardContent>
491
491
+
</Card>
492
492
+
</div>
493
493
+
)
494
494
+
}