···
import { createRoot } from 'react-dom/client'
import { Button } from '@public/components/ui/button'
10
-
} from '@public/components/ui/card'
11
-
import { Input } from '@public/components/ui/input'
12
-
import { Label } from '@public/components/ui/label'
} from '@public/components/ui/tabs'
19
-
import { Badge } from '@public/components/ui/badge'
···
} from '@public/components/ui/dialog'
18
+
import { Checkbox } from '@public/components/ui/checkbox'
19
+
import { Label } from '@public/components/ui/label'
20
+
import { Badge } from '@public/components/ui/badge'
40
-
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
-
import { Checkbox } from '@public/components/ui/checkbox'
42
-
import { CodeBlock } from '@public/components/ui/code-block'
import Layout from '@public/layouts'
46
-
interface UserInfo {
54
-
display_name: string | null
59
-
interface DomainInfo {
60
-
type: 'wisp' | 'custom'
66
-
interface SiteWithDomains extends Site {
67
-
domains?: DomainInfo[]
70
-
interface CustomDomain {
76
-
last_verified_at: number | null
80
-
interface WispDomain {
27
+
import { useUserInfo } from './hooks/useUserInfo'
28
+
import { useSiteData, type SiteWithDomains } from './hooks/useSiteData'
29
+
import { useDomainData } from './hooks/useDomainData'
30
+
import { SitesTab } from './tabs/SitesTab'
31
+
import { DomainsTab } from './tabs/DomainsTab'
32
+
import { UploadTab } from './tabs/UploadTab'
33
+
import { CLITab } from './tabs/CLITab'
87
-
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
88
-
const [loading, setLoading] = useState(true)
37
+
const { userInfo, loading, fetchUserInfo } = useUserInfo()
38
+
const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData()
51
+
checkWispAvailability
91
-
const [sites, setSites] = useState<SiteWithDomains[]>([])
92
-
const [sitesLoading, setSitesLoading] = useState(true)
93
-
const [isSyncing, setIsSyncing] = useState(false)
96
-
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
97
-
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
98
-
const [domainsLoading, setDomainsLoading] = useState(true)
100
-
// Site configuration state
54
+
// Site configuration modal state (shared across components)
const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
const [isSavingConfig, setIsSavingConfig] = useState(false)
const [isDeletingSite, setIsDeletingSite] = useState(false)
107
-
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
108
-
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
109
-
const [newSiteName, setNewSiteName] = useState('')
110
-
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
111
-
const [isUploading, setIsUploading] = useState(false)
112
-
const [uploadProgress, setUploadProgress] = useState('')
113
-
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
114
-
const [uploadedCount, setUploadedCount] = useState(0)
116
-
// Custom domain modal state
117
-
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
118
-
const [customDomain, setCustomDomain] = useState('')
119
-
const [isAddingDomain, setIsAddingDomain] = useState(false)
120
-
const [verificationStatus, setVerificationStatus] = useState<{
121
-
[id: string]: 'idle' | 'verifying' | 'success' | 'error'
123
-
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
125
-
// Wisp domain claim state
126
-
const [wispHandle, setWispHandle] = useState('')
127
-
const [isClaimingWisp, setIsClaimingWisp] = useState(false)
128
-
const [wispAvailability, setWispAvailability] = useState<{
129
-
available: boolean | null
131
-
}>({ available: null, checking: false })
133
-
// Fetch user info on mount
60
+
// Fetch initial data on mount
140
-
// Auto-switch to 'new' mode if no sites exist
142
-
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
145
-
}, [sites, sitesLoading, siteMode])
147
-
const fetchUserInfo = async () => {
149
-
const response = await fetch('/api/user/info')
150
-
const data = await response.json()
153
-
console.error('Failed to fetch user info:', err)
159
-
const fetchSites = async () => {
161
-
const response = await fetch('/api/user/sites')
162
-
const data = await response.json()
163
-
const sitesData: Site[] = data.sites || []
165
-
// Fetch domain info for each site
166
-
const sitesWithDomains = await Promise.all(
167
-
sitesData.map(async (site) => {
169
-
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
170
-
const domainsData = await domainsResponse.json()
173
-
domains: domainsData.domains || []
176
-
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
185
-
setSites(sitesWithDomains)
187
-
console.error('Failed to fetch sites:', err)
189
-
setSitesLoading(false)
193
-
const syncSites = async () => {
196
-
const response = await fetch('/api/user/sync', {
199
-
const data = await response.json()
200
-
if (data.success) {
201
-
console.log(`Synced ${data.synced} sites from PDS`)
202
-
// Refresh sites list
206
-
console.error('Failed to sync sites:', err)
207
-
alert('Failed to sync sites from PDS')
209
-
setIsSyncing(false)
213
-
const fetchDomains = async () => {
215
-
const response = await fetch('/api/user/domains')
216
-
const data = await response.json()
217
-
setWispDomain(data.wispDomain)
218
-
setCustomDomains(data.customDomains || [])
220
-
console.error('Failed to fetch domains:', err)
222
-
setDomainsLoading(false)
226
-
const getSiteUrl = (site: SiteWithDomains) => {
227
-
// Use the first mapped domain if available
228
-
if (site.domains && site.domains.length > 0) {
229
-
return `https://${site.domains[0].domain}`
232
-
// Default fallback URL - use handle instead of DID
233
-
if (!userInfo) return '#'
234
-
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
237
-
const getSiteDomainName = (site: SiteWithDomains) => {
238
-
// Return the first domain if available
239
-
if (site.domains && site.domains.length > 0) {
240
-
return site.domains[0].domain
243
-
// Use handle instead of DID for display
244
-
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
245
-
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
248
-
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
249
-
if (e.target.files && e.target.files.length > 0) {
250
-
setSelectedFiles(e.target.files)
254
-
const handleUpload = async () => {
255
-
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
258
-
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
262
-
setIsUploading(true)
263
-
setUploadProgress('Preparing files...')
266
-
const formData = new FormData()
267
-
formData.append('siteName', siteName)
269
-
if (selectedFiles) {
270
-
for (let i = 0; i < selectedFiles.length; i++) {
271
-
formData.append('files', selectedFiles[i])
275
-
setUploadProgress('Uploading to AT Protocol...')
276
-
const response = await fetch('/wisp/upload-files', {
281
-
const data = await response.json()
282
-
if (data.success) {
283
-
setUploadProgress('Upload complete!')
284
-
setSkippedFiles(data.skippedFiles || [])
285
-
setUploadedCount(data.uploadedCount || data.fileCount || 0)
286
-
setSelectedSiteRkey('')
288
-
setSelectedFiles(null)
290
-
// Refresh sites list
293
-
// Reset form - give more time if there are skipped files
294
-
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
296
-
setUploadProgress('')
297
-
setSkippedFiles([])
298
-
setUploadedCount(0)
299
-
setIsUploading(false)
302
-
throw new Error(data.error || 'Upload failed')
305
-
console.error('Upload error:', err)
307
-
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
309
-
setIsUploading(false)
310
-
setUploadProgress('')
314
-
const handleAddCustomDomain = async () => {
315
-
if (!customDomain) {
316
-
alert('Please enter a domain')
320
-
setIsAddingDomain(true)
322
-
const response = await fetch('/api/domain/custom/add', {
324
-
headers: { 'Content-Type': 'application/json' },
325
-
body: JSON.stringify({ domain: customDomain })
328
-
const data = await response.json()
329
-
if (data.success) {
330
-
setCustomDomain('')
331
-
setAddDomainModalOpen(false)
332
-
await fetchDomains()
334
-
// Automatically show DNS configuration for the newly added domain
335
-
setViewDomainDNS(data.id)
337
-
throw new Error(data.error || 'Failed to add domain')
340
-
console.error('Add domain error:', err)
342
-
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
345
-
setIsAddingDomain(false)
349
-
const handleVerifyDomain = async (id: string) => {
350
-
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
353
-
const response = await fetch('/api/domain/custom/verify', {
355
-
headers: { 'Content-Type': 'application/json' },
356
-
body: JSON.stringify({ id })
359
-
const data = await response.json()
360
-
if (data.success && data.verified) {
361
-
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
362
-
await fetchDomains()
364
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
366
-
alert(`Verification failed: ${data.error}`)
370
-
console.error('Verify domain error:', err)
371
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
373
-
`Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
378
-
const handleDeleteCustomDomain = async (id: string) => {
379
-
if (!confirm('Are you sure you want to remove this custom domain?')) {
384
-
const response = await fetch(`/api/domain/custom/${id}`, {
388
-
const data = await response.json()
389
-
if (data.success) {
390
-
await fetchDomains()
392
-
throw new Error('Failed to delete domain')
395
-
console.error('Delete domain error:', err)
397
-
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
67
+
// Handle site configuration modal
const handleConfigureSite = (site: SiteWithDomains) => {
···
// Handle wisp domain mapping
if (shouldMapWisp && !isCurrentlyMappedToWisp) {
432
-
// Map to wisp domain
433
-
const response = await fetch('/api/domain/wisp/map-site', {
435
-
headers: { 'Content-Type': 'application/json' },
436
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
438
-
const data = await response.json()
439
-
if (!data.success) throw new Error('Failed to map wisp domain')
98
+
await mapWispDomain(configuringSite.rkey)
} else if (!shouldMapWisp && isCurrentlyMappedToWisp) {
441
-
// Unmap from wisp domain
442
-
await fetch('/api/domain/wisp/map-site', {
444
-
headers: { 'Content-Type': 'application/json' },
445
-
body: JSON.stringify({ siteRkey: null })
100
+
await mapWispDomain(null)
// Handle custom domain mappings
···
// Unmap domains that are no longer selected
for (const domain of currentlyMappedCustomDomains) {
if (!selectedCustomDomainIds.includes(domain.id)) {
458
-
await fetch(`/api/domain/custom/${domain.id}/map-site`, {
460
-
headers: { 'Content-Type': 'application/json' },
461
-
body: JSON.stringify({ siteRkey: null })
112
+
await mapCustomDomain(domain.id, null)
···
for (const domainId of selectedCustomDomainIds) {
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
470
-
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
472
-
headers: { 'Content-Type': 'application/json' },
473
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
475
-
const data = await response.json()
476
-
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
120
+
await mapCustomDomain(domainId, configuringSite.rkey)
···
503
-
const response = await fetch(`/api/site/${configuringSite.rkey}`, {
507
-
const data = await response.json()
508
-
if (data.success) {
509
-
// Refresh sites list
511
-
// Refresh domains in case this site was mapped
512
-
await fetchDomains()
513
-
setConfiguringSite(null)
515
-
throw new Error(data.error || 'Failed to delete site')
518
-
console.error('Delete site error:', err)
520
-
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
523
-
setIsDeletingSite(false)
146
+
const success = await deleteSite(configuringSite.rkey)
148
+
// Refresh domains in case this site was mapped
149
+
await fetchDomains()
150
+
setConfiguringSite(null)
152
+
setIsDeletingSite(false)
527
-
const checkWispAvailability = async (handle: string) => {
528
-
const trimmedHandle = handle.trim().toLowerCase()
529
-
if (!trimmedHandle) {
530
-
setWispAvailability({ available: null, checking: false })
534
-
setWispAvailability({ available: null, checking: true })
536
-
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
537
-
const data = await response.json()
538
-
setWispAvailability({ available: data.available, checking: false })
540
-
console.error('Check availability error:', err)
541
-
setWispAvailability({ available: false, checking: false })
545
-
const handleClaimWispDomain = async () => {
546
-
const trimmedHandle = wispHandle.trim().toLowerCase()
547
-
if (!trimmedHandle) {
548
-
alert('Please enter a handle')
552
-
setIsClaimingWisp(true)
554
-
const response = await fetch('/api/domain/claim', {
556
-
headers: { 'Content-Type': 'application/json' },
557
-
body: JSON.stringify({ handle: trimmedHandle })
560
-
const data = await response.json()
561
-
if (data.success) {
563
-
setWispAvailability({ available: null, checking: false })
564
-
await fetchDomains()
566
-
throw new Error(data.error || 'Failed to claim domain')
569
-
console.error('Claim domain error:', err)
570
-
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
572
-
// Handle "Already claimed" error more gracefully
573
-
if (errorMessage.includes('Already claimed')) {
574
-
alert('You have already claimed a wisp.place subdomain. Please refresh the page.')
575
-
await fetchDomains()
577
-
alert(`Failed to claim domain: ${errorMessage}`)
580
-
setIsClaimingWisp(false)
155
+
const handleUploadComplete = async () => {
···
630
-
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
633
-
<div className="flex items-center justify-between">
635
-
<CardTitle>Your Sites</CardTitle>
637
-
View and manage all your deployed sites
643
-
onClick={syncSites}
644
-
disabled={isSyncing || sitesLoading}
647
-
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
653
-
<CardContent className="space-y-4">
655
-
<div className="flex items-center justify-center py-8">
656
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
658
-
) : sites.length === 0 ? (
659
-
<div className="text-center py-8 text-muted-foreground">
660
-
<p>No sites yet. Upload your first site!</p>
663
-
sites.map((site) => (
665
-
key={`${site.did}-${site.rkey}`}
666
-
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
668
-
<div className="flex-1">
669
-
<div className="flex items-center gap-3 mb-2">
670
-
<h3 className="font-semibold text-lg">
671
-
{site.display_name || site.rkey}
674
-
variant="secondary"
675
-
className="text-xs"
681
-
{/* Display all mapped domains */}
682
-
{site.domains && site.domains.length > 0 ? (
683
-
<div className="space-y-1">
684
-
{site.domains.map((domainInfo, idx) => (
685
-
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
687
-
href={`https://${domainInfo.domain}`}
689
-
rel="noopener noreferrer"
690
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
692
-
<Globe className="w-3 h-3" />
693
-
{domainInfo.domain}
694
-
<ExternalLink className="w-3 h-3" />
697
-
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
698
-
className="text-xs"
702
-
{domainInfo.type === 'custom' && (
704
-
variant={domainInfo.verified ? 'default' : 'secondary'}
705
-
className="text-xs"
707
-
{domainInfo.verified ? (
709
-
<CheckCircle2 className="w-3 h-3 mr-1" />
714
-
<AlertCircle className="w-3 h-3 mr-1" />
725
-
href={getSiteUrl(site)}
727
-
rel="noopener noreferrer"
728
-
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
730
-
{getSiteDomainName(site)}
731
-
<ExternalLink className="w-3 h-3" />
738
-
onClick={() => handleConfigureSite(site)}
740
-
<Settings className="w-4 h-4 mr-2" />
749
-
<div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
750
-
<div className="flex items-start gap-2">
751
-
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
752
-
<div className="flex-1 space-y-1">
753
-
<p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
754
-
Note about sites.wisp.place URLs
756
-
<p className="text-xs text-muted-foreground">
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.
205
+
<TabsContent value="sites">
208
+
sitesLoading={sitesLoading}
209
+
isSyncing={isSyncing}
210
+
userInfo={userInfo}
211
+
onSyncSites={syncSites}
212
+
onConfigureSite={handleConfigureSite}
765
-
<TabsContent value="domains" className="space-y-4 min-h-[400px]">
768
-
<CardTitle>wisp.place Subdomain</CardTitle>
770
-
Your free subdomain on the wisp.place network
774
-
{domainsLoading ? (
775
-
<div className="flex items-center justify-center py-4">
776
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
780
-
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
781
-
<div className="flex items-center gap-2">
782
-
<CheckCircle2 className="w-5 h-5 text-green-500" />
783
-
<span className="font-mono text-lg">
784
-
{wispDomain.domain}
787
-
{wispDomain.rkey && (
788
-
<p className="text-xs text-muted-foreground ml-7">
789
-
→ Mapped to site: {wispDomain.rkey}
793
-
<p className="text-sm text-muted-foreground mt-3">
795
-
? 'This domain is mapped to a specific site'
796
-
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
800
-
<div className="space-y-4">
801
-
<div className="p-4 bg-muted/30 rounded-lg">
802
-
<p className="text-sm text-muted-foreground mb-4">
803
-
Claim your free wisp.place subdomain
805
-
<div className="space-y-3">
806
-
<div className="space-y-2">
807
-
<Label htmlFor="wisp-handle">Choose your handle</Label>
808
-
<div className="flex gap-2">
809
-
<div className="flex-1 relative">
812
-
placeholder="mysite"
815
-
setWispHandle(e.target.value)
816
-
if (e.target.value.trim()) {
817
-
checkWispAvailability(e.target.value)
819
-
setWispAvailability({ available: null, checking: false })
822
-
disabled={isClaimingWisp}
825
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
830
-
{wispAvailability.checking && (
831
-
<p className="text-xs text-muted-foreground flex items-center gap-1">
832
-
<Loader2 className="w-3 h-3 animate-spin" />
833
-
Checking availability...
836
-
{!wispAvailability.checking && wispAvailability.available === true && (
837
-
<p className="text-xs text-green-600 flex items-center gap-1">
838
-
<CheckCircle2 className="w-3 h-3" />
842
-
{!wispAvailability.checking && wispAvailability.available === false && (
843
-
<p className="text-xs text-red-600 flex items-center gap-1">
844
-
<XCircle className="w-3 h-3" />
850
-
onClick={handleClaimWispDomain}
851
-
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
854
-
{isClaimingWisp ? (
856
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
872
-
<CardTitle>Custom Domains</CardTitle>
874
-
Bring your own domain with DNS verification
877
-
<CardContent className="space-y-4">
879
-
onClick={() => setAddDomainModalOpen(true)}
885
-
{domainsLoading ? (
886
-
<div className="flex items-center justify-center py-4">
887
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
889
-
) : customDomains.length === 0 ? (
890
-
<div className="text-center py-4 text-muted-foreground text-sm">
891
-
No custom domains added yet
894
-
<div className="space-y-2">
895
-
{customDomains.map((domain) => (
898
-
className="flex items-center justify-between p-3 border border-border rounded-lg"
900
-
<div className="flex flex-col gap-1 flex-1">
901
-
<div className="flex items-center gap-2">
902
-
{domain.verified ? (
903
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
905
-
<XCircle className="w-4 h-4 text-red-500" />
907
-
<span className="font-mono">
911
-
{domain.rkey && domain.rkey !== 'self' && (
912
-
<p className="text-xs text-muted-foreground ml-6">
913
-
→ Mapped to site: {domain.rkey}
917
-
<div className="flex items-center gap-2">
922
-
setViewDomainDNS(domain.id)
927
-
{domain.verified ? (
928
-
<Badge variant="secondary">
936
-
handleVerifyDomain(domain.id)
939
-
verificationStatus[
944
-
{verificationStatus[
946
-
] === 'verifying' ? (
948
-
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
960
-
handleDeleteCustomDomain(
965
-
<Trash2 className="w-4 h-4" />
217
+
<TabsContent value="domains">
219
+
wispDomain={wispDomain}
220
+
customDomains={customDomains}
221
+
domainsLoading={domainsLoading}
222
+
verificationStatus={verificationStatus}
223
+
userInfo={userInfo}
224
+
onAddCustomDomain={addCustomDomain}
225
+
onVerifyDomain={verifyDomain}
226
+
onDeleteCustomDomain={deleteCustomDomain}
227
+
onClaimWispDomain={claimWispDomain}
228
+
onCheckWispAvailability={checkWispAvailability}
977
-
<TabsContent value="upload" className="space-y-4 min-h-[400px]">
980
-
<CardTitle>Upload Site</CardTitle>
982
-
Deploy a new site from a folder or Git repository
985
-
<CardContent className="space-y-6">
986
-
<div className="space-y-4">
987
-
<div className="p-4 bg-muted/50 rounded-lg">
990
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
991
-
disabled={isUploading}
993
-
<div className="flex items-center space-x-2">
994
-
<RadioGroupItem value="existing" id="existing" />
995
-
<Label htmlFor="existing" className="cursor-pointer">
996
-
Update existing site
999
-
<div className="flex items-center space-x-2">
1000
-
<RadioGroupItem value="new" id="new" />
1001
-
<Label htmlFor="new" className="cursor-pointer">
1008
-
{siteMode === 'existing' ? (
1009
-
<div className="space-y-2">
1010
-
<Label htmlFor="site-select">Select Site</Label>
1012
-
<div className="flex items-center justify-center py-4">
1013
-
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
1015
-
) : sites.length === 0 ? (
1016
-
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
1017
-
No sites available. Create a new site instead.
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
-
value={selectedSiteRkey}
1024
-
onChange={(e) => setSelectedSiteRkey(e.target.value)}
1025
-
disabled={isUploading}
1027
-
<option value="">Select a site...</option>
1028
-
{sites.map((site) => (
1029
-
<option key={site.rkey} value={site.rkey}>
1030
-
{site.display_name || site.rkey}
1037
-
<div className="space-y-2">
1038
-
<Label htmlFor="new-site-name">New Site Name</Label>
1040
-
id="new-site-name"
1041
-
placeholder="my-awesome-site"
1042
-
value={newSiteName}
1043
-
onChange={(e) => setNewSiteName(e.target.value)}
1044
-
disabled={isUploading}
1049
-
<p className="text-xs text-muted-foreground">
1050
-
File limits: 100MB per file, 300MB total
1054
-
<div className="grid md:grid-cols-2 gap-4">
1055
-
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
1056
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
1057
-
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
1058
-
<h3 className="font-semibold mb-2">
1061
-
<p className="text-sm text-muted-foreground mb-4">
1062
-
Drag and drop or click to upload your
1069
-
onChange={handleFileSelect}
1070
-
className="hidden"
1071
-
{...(({ webkitdirectory: '', directory: '' } as any))}
1072
-
disabled={isUploading}
1074
-
<label htmlFor="file-upload">
1080
-
.getElementById('file-upload')
1083
-
disabled={isUploading}
1088
-
{selectedFiles && selectedFiles.length > 0 && (
1089
-
<p className="text-sm text-muted-foreground mt-3">
1090
-
{selectedFiles.length} files selected
1096
-
<Card className="border-2 border-dashed opacity-50">
1097
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
1098
-
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
1099
-
<h3 className="font-semibold mb-2">
1100
-
Connect Git Repository
1102
-
<p className="text-sm text-muted-foreground mb-4">
1103
-
Link your GitHub, GitLab, or any Git
1106
-
<Badge variant="secondary">Coming soon!</Badge>
1111
-
{uploadProgress && (
1112
-
<div className="space-y-3">
1113
-
<div className="p-4 bg-muted rounded-lg">
1114
-
<div className="flex items-center gap-2">
1115
-
<Loader2 className="w-4 h-4 animate-spin" />
1116
-
<span className="text-sm">{uploadProgress}</span>
1120
-
{skippedFiles.length > 0 && (
1121
-
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
1122
-
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
1123
-
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
1124
-
<div className="flex-1">
1125
-
<span className="font-medium">
1126
-
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
1128
-
{uploadedCount > 0 && (
1129
-
<span className="text-sm ml-2">
1130
-
({uploadedCount} uploaded successfully)
1135
-
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
1136
-
{skippedFiles.slice(0, 5).map((file, idx) => (
1137
-
<div key={idx} className="text-xs">
1138
-
<span className="font-mono">{file.name}</span>
1139
-
<span className="text-muted-foreground"> - {file.reason}</span>
1142
-
{skippedFiles.length > 5 && (
1143
-
<div className="text-xs text-muted-foreground">
1144
-
...and {skippedFiles.length - 5} more
1154
-
onClick={handleUpload}
1155
-
className="w-full"
1157
-
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
1159
-
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
1164
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
1169
-
{siteMode === 'existing' ? (
1172
-
selectedFiles && selectedFiles.length > 0
1173
-
? 'Upload & Deploy'
1174
-
: 'Create Empty Site'
233
+
<TabsContent value="upload">
236
+
sitesLoading={sitesLoading}
237
+
onUploadComplete={handleUploadComplete}
1184
-
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
1187
-
<div className="flex items-center gap-2 mb-2">
1188
-
<CardTitle>Wisp CLI Tool</CardTitle>
1189
-
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
1190
-
<Badge variant="outline" className="text-xs">Alpha</Badge>
1193
-
Deploy static sites directly from your terminal
1194
-
</CardDescription>
1196
-
<CardContent className="space-y-6">
1197
-
<div className="prose prose-sm max-w-none dark:prose-invert">
1198
-
<p className="text-sm text-muted-foreground">
1199
-
The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account.
1200
-
Authenticate with app password or OAuth and deploy from CI/CD pipelines.
1204
-
<div className="space-y-3">
1205
-
<h3 className="text-sm font-semibold">Download CLI</h3>
1206
-
<div className="grid gap-2">
1207
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1209
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
1211
-
rel="noopener noreferrer"
1212
-
className="flex items-center justify-between mb-2"
1214
-
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
1215
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1217
-
<div className="text-xs text-muted-foreground">
1218
-
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
1221
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1223
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
1225
-
rel="noopener noreferrer"
1226
-
className="flex items-center justify-between mb-2"
1228
-
<span className="font-mono text-sm">Linux (ARM64)</span>
1229
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1231
-
<div className="text-xs text-muted-foreground">
1232
-
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
1235
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
1237
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
1239
-
rel="noopener noreferrer"
1240
-
className="flex items-center justify-between mb-2"
1242
-
<span className="font-mono text-sm">Linux (x86_64)</span>
1243
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1245
-
<div className="text-xs text-muted-foreground">
1246
-
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
1252
-
<div className="space-y-3">
1253
-
<h3 className="text-sm font-semibold">Basic Usage</h3>
1255
-
code={`# Download and make executable
1256
-
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
1257
-
chmod +x wisp-cli-macos-arm64
1259
-
# Deploy your site (will use OAuth)
1260
-
./wisp-cli-macos-arm64 your-handle.bsky.social \\
1264
-
# Your site will be available at:
1265
-
# https://sites.wisp.place/your-handle/my-site`}
1270
-
<div className="space-y-3">
1271
-
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
1272
-
<p className="text-xs text-muted-foreground">
1273
-
Deploy automatically on every push using{' '}
1275
-
href="https://blog.tangled.org/ci"
1277
-
rel="noopener noreferrer"
1278
-
className="text-accent hover:underline"
1284
-
<div className="space-y-4">
1286
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1287
-
<span>Example 1: Simple Asset Publishing</span>
1288
-
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
1294
-
- event: ['manual']
1308
-
SITE_PATH: '.' # Copy entire repo
1309
-
SITE_NAME: 'myWebbedSite'
1310
-
WISP_HANDLE: 'your-handle.bsky.social'
1313
-
- name: deploy assets to wisp
1315
-
# Download Wisp CLI
1316
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1322
-
--path "$SITE_PATH" \\
1323
-
--site "$SITE_NAME" \\
1324
-
--password "$WISP_APP_PASSWORD"
1327
-
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
1328
-
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
1335
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
1336
-
<span>Example 2: React/Vite Build & Deploy</span>
1337
-
<Badge variant="secondary" className="text-xs">Full Build</Badge>
1343
-
- event: ['manual']
1357
-
github:NixOS/nixpkgs/nixpkgs-unstable:
1362
-
SITE_NAME: 'my-react-site'
1363
-
WISP_HANDLE: 'your-handle.bsky.social'
1366
-
- name: build site
1368
-
# necessary to ensure bun is in PATH
1369
-
export PATH="$HOME/.nix-profile/bin:$PATH"
1371
-
bun install --frozen-lockfile
1373
-
# build with vite, run directly to get around env issues
1374
-
bun node_modules/.bin/vite build
1376
-
- name: deploy to wisp
1378
-
# Download Wisp CLI
1379
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
1385
-
--path "$SITE_PATH" \\
1386
-
--site "$SITE_NAME" \\
1387
-
--password "$WISP_APP_PASSWORD"`}
1393
-
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
1394
-
<p className="text-xs text-muted-foreground">
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
-
Generate an app password from your AT Protocol account settings.
1401
-
<div className="space-y-3">
1402
-
<h3 className="text-sm font-semibold">Learn More</h3>
1403
-
<div className="grid gap-2">
1405
-
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
1407
-
rel="noopener noreferrer"
1408
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1410
-
<span className="text-sm">Source Code</span>
1411
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
1414
-
href="https://blog.tangled.org/ci"
1416
-
rel="noopener noreferrer"
1417
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
1419
-
<span className="text-sm">Tangled Spindle CI/CD</span>
1420
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
242
+
<TabsContent value="cli">
1430
-
{/* Add Custom Domain Modal */}
1431
-
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
1432
-
<DialogContent className="sm:max-w-lg">
1434
-
<DialogTitle>Add Custom Domain</DialogTitle>
1435
-
<DialogDescription>
1436
-
Enter your domain name. After adding, you'll see the DNS
1437
-
records to configure.
1438
-
</DialogDescription>
1440
-
<div className="space-y-4 py-4">
1441
-
<div className="space-y-2">
1442
-
<Label htmlFor="new-domain">Domain Name</Label>
1445
-
placeholder="example.com"
1446
-
value={customDomain}
1447
-
onChange={(e) => setCustomDomain(e.target.value)}
1449
-
<p className="text-xs text-muted-foreground">
1450
-
After adding, click "View DNS" to see the records you
1451
-
need to configure.
1455
-
<DialogFooter className="flex-col sm:flex-row gap-2">
1459
-
setAddDomainModalOpen(false)
1460
-
setCustomDomain('')
1462
-
className="w-full sm:w-auto"
1463
-
disabled={isAddingDomain}
1468
-
onClick={handleAddCustomDomain}
1469
-
disabled={!customDomain || isAddingDomain}
1470
-
className="w-full sm:w-auto"
1472
-
{isAddingDomain ? (
1474
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{/* Site Configuration Modal */}
···
1644
-
{/* View DNS Records Modal */}
1646
-
open={viewDomainDNS !== null}
1647
-
onOpenChange={(open) => !open && setViewDomainDNS(null)}
1649
-
<DialogContent className="sm:max-w-lg">
1651
-
<DialogTitle>DNS Configuration</DialogTitle>
1652
-
<DialogDescription>
1653
-
Add these DNS records to your domain provider
1654
-
</DialogDescription>
1656
-
{viewDomainDNS && userInfo && (
1659
-
const domain = customDomains.find(
1660
-
(d) => d.id === viewDomainDNS
1662
-
if (!domain) return null
1665
-
<div className="space-y-4 py-4">
1666
-
<div className="p-3 bg-muted/30 rounded-lg">
1667
-
<p className="text-sm font-medium mb-1">
1670
-
<p className="font-mono text-sm">
1675
-
<div className="space-y-3">
1676
-
<div className="p-3 bg-background rounded border border-border">
1677
-
<div className="flex justify-between items-start mb-2">
1678
-
<span className="text-xs font-semibold text-muted-foreground">
1679
-
TXT Record (Verification)
1682
-
<div className="font-mono text-xs space-y-2">
1684
-
<span className="text-muted-foreground">
1687
-
<span className="select-all">
1688
-
_wisp.{domain.domain}
1692
-
<span className="text-muted-foreground">
1695
-
<span className="select-all break-all">
1702
-
<div className="p-3 bg-background rounded border border-border">
1703
-
<div className="flex justify-between items-start mb-2">
1704
-
<span className="text-xs font-semibold text-muted-foreground">
1705
-
CNAME Record (Pointing)
1708
-
<div className="font-mono text-xs space-y-2">
1710
-
<span className="text-muted-foreground">
1713
-
<span className="select-all">
1718
-
<span className="text-muted-foreground">
1721
-
<span className="select-all">
1722
-
{domain.id}.dns.wisp.place
1726
-
<p className="text-xs text-muted-foreground mt-2">
1727
-
Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification.
1732
-
<div className="p-3 bg-muted/30 rounded-lg">
1733
-
<p className="text-xs text-muted-foreground">
1734
-
💡 After configuring DNS, click "Verify DNS"
1735
-
to check if everything is set up correctly.
1736
-
DNS changes can take a few minutes to
1748
-
onClick={() => setViewDomainDNS(null)}
1749
-
className="w-full sm:w-auto"