import { useState, useEffect } from 'react' import { createRoot } from 'react-dom/client' import { Button } from '@public/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@public/components/ui/card' import { Input } from '@public/components/ui/input' import { Label } from '@public/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@public/components/ui/tabs' import { Badge } from '@public/components/ui/badge' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@public/components/ui/dialog' import { Globe, Upload, ExternalLink, CheckCircle2, XCircle, AlertCircle, Loader2, Trash2, RefreshCw, Settings } from 'lucide-react' import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' import { CodeBlock } from '@public/components/ui/code-block' import Layout from '@public/layouts' interface UserInfo { did: string handle: string } interface Site { did: string rkey: string display_name: string | null created_at: number updated_at: number } interface CustomDomain { id: string domain: string did: string rkey: string verified: boolean last_verified_at: number | null created_at: number } interface WispDomain { domain: string rkey: string | null } function Dashboard() { // User state const [userInfo, setUserInfo] = useState(null) const [loading, setLoading] = useState(true) // Sites state const [sites, setSites] = useState([]) const [sitesLoading, setSitesLoading] = useState(true) const [isSyncing, setIsSyncing] = useState(false) // Domains state const [wispDomain, setWispDomain] = useState(null) const [customDomains, setCustomDomains] = useState([]) const [domainsLoading, setDomainsLoading] = useState(true) // Site configuration state const [configuringSite, setConfiguringSite] = useState(null) const [selectedDomain, setSelectedDomain] = useState('') const [isSavingConfig, setIsSavingConfig] = useState(false) const [isDeletingSite, setIsDeletingSite] = useState(false) // Upload state const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') const [selectedSiteRkey, setSelectedSiteRkey] = useState('') const [newSiteName, setNewSiteName] = useState('') const [selectedFiles, setSelectedFiles] = useState(null) const [isUploading, setIsUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState('') const [skippedFiles, setSkippedFiles] = useState>([]) const [uploadedCount, setUploadedCount] = useState(0) // Custom domain modal state const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) const [customDomain, setCustomDomain] = useState('') const [isAddingDomain, setIsAddingDomain] = useState(false) const [verificationStatus, setVerificationStatus] = useState<{ [id: string]: 'idle' | 'verifying' | 'success' | 'error' }>({}) const [viewDomainDNS, setViewDomainDNS] = useState(null) // Wisp domain claim state const [wispHandle, setWispHandle] = useState('') const [isClaimingWisp, setIsClaimingWisp] = useState(false) const [wispAvailability, setWispAvailability] = useState<{ available: boolean | null checking: boolean }>({ available: null, checking: false }) // Fetch user info on mount useEffect(() => { fetchUserInfo() fetchSites() fetchDomains() }, []) // Auto-switch to 'new' mode if no sites exist useEffect(() => { if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { setSiteMode('new') } }, [sites, sitesLoading, siteMode]) const fetchUserInfo = async () => { try { const response = await fetch('/api/user/info') const data = await response.json() setUserInfo(data) } catch (err) { console.error('Failed to fetch user info:', err) } finally { setLoading(false) } } const fetchSites = async () => { try { const response = await fetch('/api/user/sites') const data = await response.json() setSites(data.sites || []) } catch (err) { console.error('Failed to fetch sites:', err) } finally { setSitesLoading(false) } } const syncSites = async () => { setIsSyncing(true) try { const response = await fetch('/api/user/sync', { method: 'POST' }) const data = await response.json() if (data.success) { console.log(`Synced ${data.synced} sites from PDS`) // Refresh sites list await fetchSites() } } catch (err) { console.error('Failed to sync sites:', err) alert('Failed to sync sites from PDS') } finally { setIsSyncing(false) } } const fetchDomains = async () => { try { const response = await fetch('/api/user/domains') const data = await response.json() setWispDomain(data.wispDomain) setCustomDomains(data.customDomains || []) } catch (err) { console.error('Failed to fetch domains:', err) } finally { setDomainsLoading(false) } } const getSiteUrl = (site: Site) => { // Check if this site is mapped to the wisp.place domain if (wispDomain && wispDomain.rkey === site.rkey) { return `https://${wispDomain.domain}` } // Check if this site is mapped to any custom domain const customDomain = customDomains.find((d) => d.rkey === site.rkey) if (customDomain) { return `https://${customDomain.domain}` } // Default fallback URL if (!userInfo) return '#' return `https://sites.wisp.place/${site.did}/${site.rkey}` } const getSiteDomainName = (site: Site) => { if (wispDomain && wispDomain.rkey === site.rkey) { return wispDomain.domain } const customDomain = customDomains.find((d) => d.rkey === site.rkey) if (customDomain) { return customDomain.domain } return `sites.wisp.place/${site.did}/${site.rkey}` } const handleFileSelect = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { setSelectedFiles(e.target.files) } } const handleUpload = async () => { const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName if (!siteName) { alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') return } setIsUploading(true) setUploadProgress('Preparing files...') try { const formData = new FormData() formData.append('siteName', siteName) if (selectedFiles) { for (let i = 0; i < selectedFiles.length; i++) { formData.append('files', selectedFiles[i]) } } setUploadProgress('Uploading to AT Protocol...') const response = await fetch('/wisp/upload-files', { method: 'POST', body: formData }) const data = await response.json() if (data.success) { setUploadProgress('Upload complete!') setSkippedFiles(data.skippedFiles || []) setUploadedCount(data.uploadedCount || data.fileCount || 0) setSelectedSiteRkey('') setNewSiteName('') setSelectedFiles(null) // Refresh sites list await fetchSites() // Reset form - give more time if there are skipped files const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 setTimeout(() => { setUploadProgress('') setSkippedFiles([]) setUploadedCount(0) setIsUploading(false) }, resetDelay) } else { throw new Error(data.error || 'Upload failed') } } catch (err) { console.error('Upload error:', err) alert( `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` ) setIsUploading(false) setUploadProgress('') } } const handleAddCustomDomain = async () => { if (!customDomain) { alert('Please enter a domain') return } setIsAddingDomain(true) try { const response = await fetch('/api/domain/custom/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: customDomain }) }) const data = await response.json() if (data.success) { setCustomDomain('') setAddDomainModalOpen(false) await fetchDomains() // Automatically show DNS configuration for the newly added domain setViewDomainDNS(data.id) } else { throw new Error(data.error || 'Failed to add domain') } } catch (err) { console.error('Add domain error:', err) alert( `Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}` ) } finally { setIsAddingDomain(false) } } const handleVerifyDomain = async (id: string) => { setVerificationStatus({ ...verificationStatus, [id]: 'verifying' }) try { const response = await fetch('/api/domain/custom/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }) const data = await response.json() if (data.success && data.verified) { setVerificationStatus({ ...verificationStatus, [id]: 'success' }) await fetchDomains() } else { setVerificationStatus({ ...verificationStatus, [id]: 'error' }) if (data.error) { alert(`Verification failed: ${data.error}`) } } } catch (err) { console.error('Verify domain error:', err) setVerificationStatus({ ...verificationStatus, [id]: 'error' }) alert( `Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}` ) } } const handleDeleteCustomDomain = async (id: string) => { if (!confirm('Are you sure you want to remove this custom domain?')) { return } try { const response = await fetch(`/api/domain/custom/${id}`, { method: 'DELETE' }) const data = await response.json() if (data.success) { await fetchDomains() } else { throw new Error('Failed to delete domain') } } catch (err) { console.error('Delete domain error:', err) alert( `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` ) } } const handleConfigureSite = (site: Site) => { setConfiguringSite(site) // Determine current domain mapping if (wispDomain && wispDomain.rkey === site.rkey) { setSelectedDomain('wisp') } else { const customDomain = customDomains.find((d) => d.rkey === site.rkey) if (customDomain) { setSelectedDomain(customDomain.id) } else { setSelectedDomain('none') } } } const handleSaveSiteConfig = async () => { if (!configuringSite) return setIsSavingConfig(true) try { if (selectedDomain === 'wisp') { // Map to wisp.place domain const response = await fetch('/api/domain/wisp/map-site', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteRkey: configuringSite.rkey }) }) const data = await response.json() if (!data.success) throw new Error('Failed to map site') } else if (selectedDomain === 'none') { // Unmap from all domains // Unmap wisp domain if this site was mapped to it if (wispDomain && wispDomain.rkey === configuringSite.rkey) { await fetch('/api/domain/wisp/map-site', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteRkey: null }) }) } // Unmap from custom domains const mappedCustom = customDomains.find( (d) => d.rkey === configuringSite.rkey ) if (mappedCustom) { await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteRkey: null }) }) } } else { // Map to a custom domain const response = await fetch( `/api/domain/custom/${selectedDomain}/map-site`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ siteRkey: configuringSite.rkey }) } ) const data = await response.json() if (!data.success) throw new Error('Failed to map site') } // Refresh domains to get updated mappings await fetchDomains() setConfiguringSite(null) } catch (err) { console.error('Save config error:', err) alert( `Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}` ) } finally { setIsSavingConfig(false) } } const handleDeleteSite = async () => { if (!configuringSite) return if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) { return } setIsDeletingSite(true) try { const response = await fetch(`/api/site/${configuringSite.rkey}`, { method: 'DELETE' }) const data = await response.json() if (data.success) { // Refresh sites list await fetchSites() // Refresh domains in case this site was mapped await fetchDomains() setConfiguringSite(null) } else { throw new Error(data.error || 'Failed to delete site') } } catch (err) { console.error('Delete site error:', err) alert( `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` ) } finally { setIsDeletingSite(false) } } const checkWispAvailability = async (handle: string) => { const trimmedHandle = handle.trim().toLowerCase() if (!trimmedHandle) { setWispAvailability({ available: null, checking: false }) return } setWispAvailability({ available: null, checking: true }) try { const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) const data = await response.json() setWispAvailability({ available: data.available, checking: false }) } catch (err) { console.error('Check availability error:', err) setWispAvailability({ available: false, checking: false }) } } const handleClaimWispDomain = async () => { const trimmedHandle = wispHandle.trim().toLowerCase() if (!trimmedHandle) { alert('Please enter a handle') return } setIsClaimingWisp(true) try { const response = await fetch('/api/domain/claim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ handle: trimmedHandle }) }) const data = await response.json() if (data.success) { setWispHandle('') setWispAvailability({ available: null, checking: false }) await fetchDomains() } else { throw new Error(data.error || 'Failed to claim domain') } } catch (err) { console.error('Claim domain error:', err) const errorMessage = err instanceof Error ? err.message : 'Unknown error' // Handle "Already claimed" error more gracefully if (errorMessage.includes('Already claimed')) { alert('You have already claimed a wisp.place subdomain. Please refresh the page.') await fetchDomains() } else { alert(`Failed to claim domain: ${errorMessage}`) } } finally { setIsClaimingWisp(false) } } if (loading) { return (
) } return (
{/* Header */}
wisp.place
{userInfo?.handle || 'Loading...'}

Dashboard

Manage your sites and domains

Sites Domains Upload CLI {/* Sites Tab */}
Your Sites View and manage all your deployed sites
{sitesLoading ? (
) : sites.length === 0 ? (

No sites yet. Upload your first site!

) : ( sites.map((site) => (

{site.display_name || site.rkey}

active
{getSiteDomainName(site)}
)) )}
{/* Domains Tab */} wisp.place Subdomain Your free subdomain on the wisp.place network {domainsLoading ? (
) : wispDomain ? ( <>
{wispDomain.domain}
{wispDomain.rkey && (

→ Mapped to site: {wispDomain.rkey}

)}

{wispDomain.rkey ? 'This domain is mapped to a specific site' : 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}

) : (

Claim your free wisp.place subdomain

{ setWispHandle(e.target.value) if (e.target.value.trim()) { checkWispAvailability(e.target.value) } else { setWispAvailability({ available: null, checking: false }) } }} disabled={isClaimingWisp} className="pr-24" /> .wisp.place
{wispAvailability.checking && (

Checking availability...

)} {!wispAvailability.checking && wispAvailability.available === true && (

Available

)} {!wispAvailability.checking && wispAvailability.available === false && (

Not available

)}
)}
Custom Domains Bring your own domain with DNS verification {domainsLoading ? (
) : customDomains.length === 0 ? (
No custom domains added yet
) : (
{customDomains.map((domain) => (
{domain.verified ? ( ) : ( )} {domain.domain}
{domain.rkey && domain.rkey !== 'self' && (

→ Mapped to site: {domain.rkey}

)}
{domain.verified ? ( Verified ) : ( )}
))}
)}
{/* Upload Tab */} Upload Site Deploy a new site from a folder or Git repository
setSiteMode(value as 'existing' | 'new')} disabled={isUploading} >
{siteMode === 'existing' ? (
{sitesLoading ? (
) : sites.length === 0 ? (
No sites available. Create a new site instead.
) : ( )}
) : (
setNewSiteName(e.target.value)} disabled={isUploading} />
)}

File limits: 100MB per file, 300MB total

Upload Folder

Drag and drop or click to upload your static site files

{selectedFiles && selectedFiles.length > 0 && (

{selectedFiles.length} files selected

)}

Connect Git Repository

Link your GitHub, GitLab, or any Git repository

Coming soon!
{uploadProgress && (
{uploadProgress}
{skippedFiles.length > 0 && (
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped {uploadedCount > 0 && ( ({uploadedCount} uploaded successfully) )}
{skippedFiles.slice(0, 5).map((file, idx) => (
{file.name} - {file.reason}
))} {skippedFiles.length > 5 && (
...and {skippedFiles.length - 5} more
)}
)}
)}
{/* CLI Tab */}
Wisp CLI Tool v0.1.0 Alpha
Deploy static sites directly from your terminal

The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. Authenticate with app password or OAuth and deploy from CI/CD pipelines.

Download CLI

macOS (Apple Silicon)
SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae
Linux (ARM64)
SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a
Linux (x86_64)
SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2

Basic Usage

CI/CD with Tangled Spindle

Deploy automatically on every push using{' '} Tangled Spindle

Example 1: Simple Asset Publishing Copy Files

Example 2: React/Vite Build & Deploy Full Build

Note: Set WISP_APP_PASSWORD as a secret in your Tangled Spindle repository settings. Generate an app password from your AT Protocol account settings.

{/* Add Custom Domain Modal */} Add Custom Domain Enter your domain name. After adding, you'll see the DNS records to configure.
setCustomDomain(e.target.value)} />

After adding, click "View DNS" to see the records you need to configure.

{/* Site Configuration Modal */} !open && setConfiguringSite(null)} > Configure Site Domain Choose which domain this site should use {configuringSite && (

Site:

{configuringSite.display_name || configuringSite.rkey}

{wispDomain && (
)} {customDomains .filter((d) => d.verified) .map((domain) => (
))}
)}
{/* View DNS Records Modal */} !open && setViewDomainDNS(null)} > DNS Configuration Add these DNS records to your domain provider {viewDomainDNS && userInfo && ( <> {(() => { const domain = customDomains.find( (d) => d.id === viewDomainDNS ) if (!domain) return null return (

Domain:

{domain.domain}

TXT Record (Verification)
Name: {' '} _wisp.{domain.domain}
Value: {' '} {userInfo.did}
CNAME Record (Pointing)
Name: {' '} {domain.domain}
Value: {' '} {domain.id}.dns.wisp.place

Some DNS providers may require you to use @ or leave it blank for the root domain

💡 After configuring DNS, click "Verify DNS" to check if everything is set up correctly. DNS changes can take a few minutes to propagate.

) })()} )}
) } const root = createRoot(document.getElementById('elysia')!) root.render( )