Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

init work

+65
public/editor/components/TabSkeleton.tsx
···
+
import {
+
Card,
+
CardContent,
+
CardDescription,
+
CardHeader,
+
CardTitle
+
} from '@public/components/ui/card'
+
+
// Shimmer animation for skeleton loading
+
const Shimmer = () => (
+
<div className="animate-pulse">
+
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
+
<div className="h-4 bg-muted rounded w-1/2"></div>
+
</div>
+
)
+
+
const SkeletonLine = ({ className = '' }: { className?: string }) => (
+
<div className={`animate-pulse bg-muted rounded ${className}`}></div>
+
)
+
+
export function TabSkeleton() {
+
return (
+
<div className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<div className="space-y-2">
+
<SkeletonLine className="h-6 w-1/3" />
+
<SkeletonLine className="h-4 w-2/3" />
+
</div>
+
</CardHeader>
+
<CardContent className="space-y-4">
+
{/* Skeleton content items */}
+
<div className="p-4 border border-border rounded-lg">
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
+
<SkeletonLine className="h-4 w-2/3" />
+
</div>
+
<div className="p-4 border border-border rounded-lg">
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
+
<SkeletonLine className="h-4 w-2/3" />
+
</div>
+
<div className="p-4 border border-border rounded-lg">
+
<SkeletonLine className="h-5 w-1/2 mb-3" />
+
<SkeletonLine className="h-4 w-3/4 mb-2" />
+
<SkeletonLine className="h-4 w-2/3" />
+
</div>
+
</CardContent>
+
</Card>
+
+
<Card>
+
<CardHeader>
+
<div className="space-y-2">
+
<SkeletonLine className="h-6 w-1/4" />
+
<SkeletonLine className="h-4 w-1/2" />
+
</div>
+
</CardHeader>
+
<CardContent className="space-y-3">
+
<SkeletonLine className="h-10 w-full" />
+
<SkeletonLine className="h-4 w-3/4" />
+
</CardContent>
+
</Card>
+
</div>
+
)
+
}
+73 -1423
public/editor/editor.tsx
···
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,
···
DialogTitle,
DialogFooter
} from '@public/components/ui/dialog'
+
import { Checkbox } from '@public/components/ui/checkbox'
+
import { Label } from '@public/components/ui/label'
+
import { Badge } from '@public/components/ui/badge'
import {
Globe,
-
Upload,
-
ExternalLink,
-
CheckCircle2,
-
XCircle,
-
AlertCircle,
Loader2,
-
Trash2,
-
RefreshCw,
-
Settings
+
Trash2
} from 'lucide-react'
-
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
-
import { Checkbox } from '@public/components/ui/checkbox'
-
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 DomainInfo {
-
type: 'wisp' | 'custom'
-
domain: string
-
verified?: boolean
-
id?: string
-
}
-
-
interface SiteWithDomains extends Site {
-
domains?: DomainInfo[]
-
}
-
-
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
-
}
+
import { useUserInfo } from './hooks/useUserInfo'
+
import { useSiteData, type SiteWithDomains } from './hooks/useSiteData'
+
import { useDomainData } from './hooks/useDomainData'
+
import { SitesTab } from './tabs/SitesTab'
+
import { DomainsTab } from './tabs/DomainsTab'
+
import { UploadTab } from './tabs/UploadTab'
+
import { CLITab } from './tabs/CLITab'
function Dashboard() {
-
// User state
-
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
-
const [loading, setLoading] = useState(true)
+
// Use custom hooks
+
const { userInfo, loading, fetchUserInfo } = useUserInfo()
+
const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData()
+
const {
+
wispDomain,
+
customDomains,
+
domainsLoading,
+
verificationStatus,
+
fetchDomains,
+
addCustomDomain,
+
verifyDomain,
+
deleteCustomDomain,
+
mapWispDomain,
+
mapCustomDomain,
+
claimWispDomain,
+
checkWispAvailability
+
} = useDomainData()
-
// Sites state
-
const [sites, setSites] = useState<SiteWithDomains[]>([])
-
const [sitesLoading, setSitesLoading] = useState(true)
-
const [isSyncing, setIsSyncing] = useState(false)
-
-
// Domains state
-
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
-
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
-
const [domainsLoading, setDomainsLoading] = useState(true)
-
-
// Site configuration state
+
// 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)
-
// Upload state
-
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
-
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
-
const [newSiteName, setNewSiteName] = useState('')
-
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
-
const [isUploading, setIsUploading] = useState(false)
-
const [uploadProgress, setUploadProgress] = useState('')
-
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
-
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<string | null>(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
+
// Fetch initial data 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()
-
const sitesData: Site[] = data.sites || []
-
-
// Fetch domain info for each site
-
const sitesWithDomains = await Promise.all(
-
sitesData.map(async (site) => {
-
try {
-
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
-
const domainsData = await domainsResponse.json()
-
return {
-
...site,
-
domains: domainsData.domains || []
-
}
-
} catch (err) {
-
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
-
return {
-
...site,
-
domains: []
-
}
-
}
-
})
-
)
-
-
setSites(sitesWithDomains)
-
} 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: SiteWithDomains) => {
-
// Use the first mapped domain if available
-
if (site.domains && site.domains.length > 0) {
-
return `https://${site.domains[0].domain}`
-
}
-
-
// Default fallback URL - use handle instead of DID
-
if (!userInfo) return '#'
-
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
-
}
-
-
const getSiteDomainName = (site: SiteWithDomains) => {
-
// Return the first domain if available
-
if (site.domains && site.domains.length > 0) {
-
return site.domains[0].domain
-
}
-
-
// Use handle instead of DID for display
-
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
-
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
-
}
-
-
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
-
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'}`
-
)
-
}
-
}
-
+
// Handle site configuration modal
const handleConfigureSite = (site: SiteWithDomains) => {
setConfiguringSite(site)
···
// Handle wisp domain mapping
if (shouldMapWisp && !isCurrentlyMappedToWisp) {
-
// Map to wisp 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 wisp domain')
+
await mapWispDomain(configuringSite.rkey)
} else if (!shouldMapWisp && isCurrentlyMappedToWisp) {
-
// Unmap from wisp domain
-
await fetch('/api/domain/wisp/map-site', {
-
method: 'POST',
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ siteRkey: null })
-
})
+
await mapWispDomain(null)
}
// Handle custom domain mappings
···
// Unmap domains that are no longer selected
for (const domain of currentlyMappedCustomDomains) {
if (!selectedCustomDomainIds.includes(domain.id)) {
-
await fetch(`/api/domain/custom/${domain.id}/map-site`, {
-
method: 'POST',
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ siteRkey: null })
-
})
+
await mapCustomDomain(domain.id, null)
}
}
···
for (const domainId of selectedCustomDomainIds) {
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
if (!isAlreadyMapped) {
-
const response = await fetch(`/api/domain/custom/${domainId}/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 custom domain ${domainId}`)
+
await mapCustomDomain(domainId, configuringSite.rkey)
}
}
···
}
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 success = await deleteSite(configuringSite.rkey)
+
if (success) {
+
// Refresh domains in case this site was mapped
+
await fetchDomains()
+
setConfiguringSite(null)
}
+
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)
-
}
+
const handleUploadComplete = async () => {
+
await fetchSites()
}
if (loading) {
···
</TabsList>
{/* Sites Tab */}
-
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
-
<Card>
-
<CardHeader>
-
<div className="flex items-center justify-between">
-
<div>
-
<CardTitle>Your Sites</CardTitle>
-
<CardDescription>
-
View and manage all your deployed sites
-
</CardDescription>
-
</div>
-
<Button
-
variant="outline"
-
size="sm"
-
onClick={syncSites}
-
disabled={isSyncing || sitesLoading}
-
>
-
<RefreshCw
-
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
-
/>
-
Sync from PDS
-
</Button>
-
</div>
-
</CardHeader>
-
<CardContent className="space-y-4">
-
{sitesLoading ? (
-
<div className="flex items-center justify-center py-8">
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
-
</div>
-
) : sites.length === 0 ? (
-
<div className="text-center py-8 text-muted-foreground">
-
<p>No sites yet. Upload your first site!</p>
-
</div>
-
) : (
-
sites.map((site) => (
-
<div
-
key={`${site.did}-${site.rkey}`}
-
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
-
>
-
<div className="flex-1">
-
<div className="flex items-center gap-3 mb-2">
-
<h3 className="font-semibold text-lg">
-
{site.display_name || site.rkey}
-
</h3>
-
<Badge
-
variant="secondary"
-
className="text-xs"
-
>
-
active
-
</Badge>
-
</div>
-
-
{/* Display all mapped domains */}
-
{site.domains && site.domains.length > 0 ? (
-
<div className="space-y-1">
-
{site.domains.map((domainInfo, idx) => (
-
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
-
<a
-
href={`https://${domainInfo.domain}`}
-
target="_blank"
-
rel="noopener noreferrer"
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
-
>
-
<Globe className="w-3 h-3" />
-
{domainInfo.domain}
-
<ExternalLink className="w-3 h-3" />
-
</a>
-
<Badge
-
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
-
className="text-xs"
-
>
-
{domainInfo.type}
-
</Badge>
-
{domainInfo.type === 'custom' && (
-
<Badge
-
variant={domainInfo.verified ? 'default' : 'secondary'}
-
className="text-xs"
-
>
-
{domainInfo.verified ? (
-
<>
-
<CheckCircle2 className="w-3 h-3 mr-1" />
-
verified
-
</>
-
) : (
-
<>
-
<AlertCircle className="w-3 h-3 mr-1" />
-
pending
-
</>
-
)}
-
</Badge>
-
)}
-
</div>
-
))}
-
</div>
-
) : (
-
<a
-
href={getSiteUrl(site)}
-
target="_blank"
-
rel="noopener noreferrer"
-
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
-
>
-
{getSiteDomainName(site)}
-
<ExternalLink className="w-3 h-3" />
-
</a>
-
)}
-
</div>
-
<Button
-
variant="outline"
-
size="sm"
-
onClick={() => handleConfigureSite(site)}
-
>
-
<Settings className="w-4 h-4 mr-2" />
-
Configure
-
</Button>
-
</div>
-
))
-
)}
-
</CardContent>
-
</Card>
-
-
<div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
-
<div className="flex items-start gap-2">
-
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
-
<div className="flex-1 space-y-1">
-
<p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
-
Note about sites.wisp.place URLs
-
</p>
-
<p className="text-xs text-muted-foreground">
-
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.
-
</p>
-
</div>
-
</div>
-
</div>
+
<TabsContent value="sites">
+
<SitesTab
+
sites={sites}
+
sitesLoading={sitesLoading}
+
isSyncing={isSyncing}
+
userInfo={userInfo}
+
onSyncSites={syncSites}
+
onConfigureSite={handleConfigureSite}
+
/>
</TabsContent>
{/* Domains Tab */}
-
<TabsContent value="domains" className="space-y-4 min-h-[400px]">
-
<Card>
-
<CardHeader>
-
<CardTitle>wisp.place Subdomain</CardTitle>
-
<CardDescription>
-
Your free subdomain on the wisp.place network
-
</CardDescription>
-
</CardHeader>
-
<CardContent>
-
{domainsLoading ? (
-
<div className="flex items-center justify-center py-4">
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
-
</div>
-
) : wispDomain ? (
-
<>
-
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
-
<div className="flex items-center gap-2">
-
<CheckCircle2 className="w-5 h-5 text-green-500" />
-
<span className="font-mono text-lg">
-
{wispDomain.domain}
-
</span>
-
</div>
-
{wispDomain.rkey && (
-
<p className="text-xs text-muted-foreground ml-7">
-
→ Mapped to site: {wispDomain.rkey}
-
</p>
-
)}
-
</div>
-
<p className="text-sm text-muted-foreground mt-3">
-
{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.'}
-
</p>
-
</>
-
) : (
-
<div className="space-y-4">
-
<div className="p-4 bg-muted/30 rounded-lg">
-
<p className="text-sm text-muted-foreground mb-4">
-
Claim your free wisp.place subdomain
-
</p>
-
<div className="space-y-3">
-
<div className="space-y-2">
-
<Label htmlFor="wisp-handle">Choose your handle</Label>
-
<div className="flex gap-2">
-
<div className="flex-1 relative">
-
<Input
-
id="wisp-handle"
-
placeholder="mysite"
-
value={wispHandle}
-
onChange={(e) => {
-
setWispHandle(e.target.value)
-
if (e.target.value.trim()) {
-
checkWispAvailability(e.target.value)
-
} else {
-
setWispAvailability({ available: null, checking: false })
-
}
-
}}
-
disabled={isClaimingWisp}
-
className="pr-24"
-
/>
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
-
.wisp.place
-
</span>
-
</div>
-
</div>
-
{wispAvailability.checking && (
-
<p className="text-xs text-muted-foreground flex items-center gap-1">
-
<Loader2 className="w-3 h-3 animate-spin" />
-
Checking availability...
-
</p>
-
)}
-
{!wispAvailability.checking && wispAvailability.available === true && (
-
<p className="text-xs text-green-600 flex items-center gap-1">
-
<CheckCircle2 className="w-3 h-3" />
-
Available
-
</p>
-
)}
-
{!wispAvailability.checking && wispAvailability.available === false && (
-
<p className="text-xs text-red-600 flex items-center gap-1">
-
<XCircle className="w-3 h-3" />
-
Not available
-
</p>
-
)}
-
</div>
-
<Button
-
onClick={handleClaimWispDomain}
-
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
-
className="w-full"
-
>
-
{isClaimingWisp ? (
-
<>
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
-
Claiming...
-
</>
-
) : (
-
'Claim Subdomain'
-
)}
-
</Button>
-
</div>
-
</div>
-
</div>
-
)}
-
</CardContent>
-
</Card>
-
-
<Card>
-
<CardHeader>
-
<CardTitle>Custom Domains</CardTitle>
-
<CardDescription>
-
Bring your own domain with DNS verification
-
</CardDescription>
-
</CardHeader>
-
<CardContent className="space-y-4">
-
<Button
-
onClick={() => setAddDomainModalOpen(true)}
-
className="w-full"
-
>
-
Add Custom Domain
-
</Button>
-
-
{domainsLoading ? (
-
<div className="flex items-center justify-center py-4">
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
-
</div>
-
) : customDomains.length === 0 ? (
-
<div className="text-center py-4 text-muted-foreground text-sm">
-
No custom domains added yet
-
</div>
-
) : (
-
<div className="space-y-2">
-
{customDomains.map((domain) => (
-
<div
-
key={domain.id}
-
className="flex items-center justify-between p-3 border border-border rounded-lg"
-
>
-
<div className="flex flex-col gap-1 flex-1">
-
<div className="flex items-center gap-2">
-
{domain.verified ? (
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
-
) : (
-
<XCircle className="w-4 h-4 text-red-500" />
-
)}
-
<span className="font-mono">
-
{domain.domain}
-
</span>
-
</div>
-
{domain.rkey && domain.rkey !== 'self' && (
-
<p className="text-xs text-muted-foreground ml-6">
-
→ Mapped to site: {domain.rkey}
-
</p>
-
)}
-
</div>
-
<div className="flex items-center gap-2">
-
<Button
-
variant="outline"
-
size="sm"
-
onClick={() =>
-
setViewDomainDNS(domain.id)
-
}
-
>
-
View DNS
-
</Button>
-
{domain.verified ? (
-
<Badge variant="secondary">
-
Verified
-
</Badge>
-
) : (
-
<Button
-
variant="outline"
-
size="sm"
-
onClick={() =>
-
handleVerifyDomain(domain.id)
-
}
-
disabled={
-
verificationStatus[
-
domain.id
-
] === 'verifying'
-
}
-
>
-
{verificationStatus[
-
domain.id
-
] === 'verifying' ? (
-
<>
-
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
-
Verifying...
-
</>
-
) : (
-
'Verify DNS'
-
)}
-
</Button>
-
)}
-
<Button
-
variant="ghost"
-
size="sm"
-
onClick={() =>
-
handleDeleteCustomDomain(
-
domain.id
-
)
-
}
-
>
-
<Trash2 className="w-4 h-4" />
-
</Button>
-
</div>
-
</div>
-
))}
-
</div>
-
)}
-
</CardContent>
-
</Card>
+
<TabsContent value="domains">
+
<DomainsTab
+
wispDomain={wispDomain}
+
customDomains={customDomains}
+
domainsLoading={domainsLoading}
+
verificationStatus={verificationStatus}
+
userInfo={userInfo}
+
onAddCustomDomain={addCustomDomain}
+
onVerifyDomain={verifyDomain}
+
onDeleteCustomDomain={deleteCustomDomain}
+
onClaimWispDomain={claimWispDomain}
+
onCheckWispAvailability={checkWispAvailability}
+
/>
</TabsContent>
{/* Upload Tab */}
-
<TabsContent value="upload" className="space-y-4 min-h-[400px]">
-
<Card>
-
<CardHeader>
-
<CardTitle>Upload Site</CardTitle>
-
<CardDescription>
-
Deploy a new site from a folder or Git repository
-
</CardDescription>
-
</CardHeader>
-
<CardContent className="space-y-6">
-
<div className="space-y-4">
-
<div className="p-4 bg-muted/50 rounded-lg">
-
<RadioGroup
-
value={siteMode}
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
-
disabled={isUploading}
-
>
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="existing" id="existing" />
-
<Label htmlFor="existing" className="cursor-pointer">
-
Update existing site
-
</Label>
-
</div>
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="new" id="new" />
-
<Label htmlFor="new" className="cursor-pointer">
-
Create new site
-
</Label>
-
</div>
-
</RadioGroup>
-
</div>
-
-
{siteMode === 'existing' ? (
-
<div className="space-y-2">
-
<Label htmlFor="site-select">Select Site</Label>
-
{sitesLoading ? (
-
<div className="flex items-center justify-center py-4">
-
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
-
</div>
-
) : sites.length === 0 ? (
-
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
-
No sites available. Create a new site instead.
-
</div>
-
) : (
-
<select
-
id="site-select"
-
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"
-
value={selectedSiteRkey}
-
onChange={(e) => setSelectedSiteRkey(e.target.value)}
-
disabled={isUploading}
-
>
-
<option value="">Select a site...</option>
-
{sites.map((site) => (
-
<option key={site.rkey} value={site.rkey}>
-
{site.display_name || site.rkey}
-
</option>
-
))}
-
</select>
-
)}
-
</div>
-
) : (
-
<div className="space-y-2">
-
<Label htmlFor="new-site-name">New Site Name</Label>
-
<Input
-
id="new-site-name"
-
placeholder="my-awesome-site"
-
value={newSiteName}
-
onChange={(e) => setNewSiteName(e.target.value)}
-
disabled={isUploading}
-
/>
-
</div>
-
)}
-
-
<p className="text-xs text-muted-foreground">
-
File limits: 100MB per file, 300MB total
-
</p>
-
</div>
-
-
<div className="grid md:grid-cols-2 gap-4">
-
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
-
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
-
<h3 className="font-semibold mb-2">
-
Upload Folder
-
</h3>
-
<p className="text-sm text-muted-foreground mb-4">
-
Drag and drop or click to upload your
-
static site files
-
</p>
-
<input
-
type="file"
-
id="file-upload"
-
multiple
-
onChange={handleFileSelect}
-
className="hidden"
-
{...(({ webkitdirectory: '', directory: '' } as any))}
-
disabled={isUploading}
-
/>
-
<label htmlFor="file-upload">
-
<Button
-
variant="outline"
-
type="button"
-
onClick={() =>
-
document
-
.getElementById('file-upload')
-
?.click()
-
}
-
disabled={isUploading}
-
>
-
Choose Folder
-
</Button>
-
</label>
-
{selectedFiles && selectedFiles.length > 0 && (
-
<p className="text-sm text-muted-foreground mt-3">
-
{selectedFiles.length} files selected
-
</p>
-
)}
-
</CardContent>
-
</Card>
-
-
<Card className="border-2 border-dashed opacity-50">
-
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
-
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
-
<h3 className="font-semibold mb-2">
-
Connect Git Repository
-
</h3>
-
<p className="text-sm text-muted-foreground mb-4">
-
Link your GitHub, GitLab, or any Git
-
repository
-
</p>
-
<Badge variant="secondary">Coming soon!</Badge>
-
</CardContent>
-
</Card>
-
</div>
-
-
{uploadProgress && (
-
<div className="space-y-3">
-
<div className="p-4 bg-muted rounded-lg">
-
<div className="flex items-center gap-2">
-
<Loader2 className="w-4 h-4 animate-spin" />
-
<span className="text-sm">{uploadProgress}</span>
-
</div>
-
</div>
-
-
{skippedFiles.length > 0 && (
-
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
-
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
-
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
-
<div className="flex-1">
-
<span className="font-medium">
-
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
-
</span>
-
{uploadedCount > 0 && (
-
<span className="text-sm ml-2">
-
({uploadedCount} uploaded successfully)
-
</span>
-
)}
-
</div>
-
</div>
-
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
-
{skippedFiles.slice(0, 5).map((file, idx) => (
-
<div key={idx} className="text-xs">
-
<span className="font-mono">{file.name}</span>
-
<span className="text-muted-foreground"> - {file.reason}</span>
-
</div>
-
))}
-
{skippedFiles.length > 5 && (
-
<div className="text-xs text-muted-foreground">
-
...and {skippedFiles.length - 5} more
-
</div>
-
)}
-
</div>
-
</div>
-
)}
-
</div>
-
)}
-
-
<Button
-
onClick={handleUpload}
-
className="w-full"
-
disabled={
-
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
-
isUploading ||
-
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
-
}
-
>
-
{isUploading ? (
-
<>
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
-
Uploading...
-
</>
-
) : (
-
<>
-
{siteMode === 'existing' ? (
-
'Update Site'
-
) : (
-
selectedFiles && selectedFiles.length > 0
-
? 'Upload & Deploy'
-
: 'Create Empty Site'
-
)}
-
</>
-
)}
-
</Button>
-
</CardContent>
-
</Card>
+
<TabsContent value="upload">
+
<UploadTab
+
sites={sites}
+
sitesLoading={sitesLoading}
+
onUploadComplete={handleUploadComplete}
+
/>
</TabsContent>
{/* CLI Tab */}
-
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
-
<Card>
-
<CardHeader>
-
<div className="flex items-center gap-2 mb-2">
-
<CardTitle>Wisp CLI Tool</CardTitle>
-
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
-
<Badge variant="outline" className="text-xs">Alpha</Badge>
-
</div>
-
<CardDescription>
-
Deploy static sites directly from your terminal
-
</CardDescription>
-
</CardHeader>
-
<CardContent className="space-y-6">
-
<div className="prose prose-sm max-w-none dark:prose-invert">
-
<p className="text-sm text-muted-foreground">
-
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.
-
</p>
-
</div>
-
-
<div className="space-y-3">
-
<h3 className="text-sm font-semibold">Download CLI</h3>
-
<div className="grid gap-2">
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
-
<a
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="flex items-center justify-between mb-2"
-
>
-
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
-
</a>
-
<div className="text-xs text-muted-foreground">
-
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
-
</div>
-
</div>
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
-
<a
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="flex items-center justify-between mb-2"
-
>
-
<span className="font-mono text-sm">Linux (ARM64)</span>
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
-
</a>
-
<div className="text-xs text-muted-foreground">
-
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
-
</div>
-
</div>
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
-
<a
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="flex items-center justify-between mb-2"
-
>
-
<span className="font-mono text-sm">Linux (x86_64)</span>
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
-
</a>
-
<div className="text-xs text-muted-foreground">
-
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
-
</div>
-
</div>
-
</div>
-
</div>
-
-
<div className="space-y-3">
-
<h3 className="text-sm font-semibold">Basic Usage</h3>
-
<CodeBlock
-
code={`# Download and make executable
-
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
-
chmod +x wisp-cli-macos-arm64
-
-
# Deploy your site (will use OAuth)
-
./wisp-cli-macos-arm64 your-handle.bsky.social \\
-
--path ./dist \\
-
--site my-site
-
-
# Your site will be available at:
-
# https://sites.wisp.place/your-handle/my-site`}
-
language="bash"
-
/>
-
</div>
-
-
<div className="space-y-3">
-
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
-
<p className="text-xs text-muted-foreground">
-
Deploy automatically on every push using{' '}
-
<a
-
href="https://blog.tangled.org/ci"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="text-accent hover:underline"
-
>
-
Tangled Spindle
-
</a>
-
</p>
-
-
<div className="space-y-4">
-
<div>
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
-
<span>Example 1: Simple Asset Publishing</span>
-
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
-
</h4>
-
<CodeBlock
-
code={`when:
-
- event: ['push']
-
branch: ['main']
-
- event: ['manual']
-
-
engine: 'nixery'
-
-
clone:
-
skip: false
-
depth: 1
-
-
dependencies:
-
nixpkgs:
-
- coreutils
-
- curl
-
-
environment:
-
SITE_PATH: '.' # Copy entire repo
-
SITE_NAME: 'myWebbedSite'
-
WISP_HANDLE: 'your-handle.bsky.social'
-
-
steps:
-
- name: deploy assets to wisp
-
command: |
-
# Download Wisp CLI
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
-
chmod +x wisp-cli
-
-
# Deploy to Wisp
-
./wisp-cli \\
-
"$WISP_HANDLE" \\
-
--path "$SITE_PATH" \\
-
--site "$SITE_NAME" \\
-
--password "$WISP_APP_PASSWORD"
-
-
# Output
-
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
-
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
-
`}
-
language="yaml"
-
/>
-
</div>
-
-
<div>
-
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
-
<span>Example 2: React/Vite Build & Deploy</span>
-
<Badge variant="secondary" className="text-xs">Full Build</Badge>
-
</h4>
-
<CodeBlock
-
code={`when:
-
- event: ['push']
-
branch: ['main']
-
- event: ['manual']
-
-
engine: 'nixery'
-
-
clone:
-
skip: false
-
depth: 1
-
submodules: false
-
-
dependencies:
-
nixpkgs:
-
- nodejs
-
- coreutils
-
- curl
-
github:NixOS/nixpkgs/nixpkgs-unstable:
-
- bun
-
-
environment:
-
SITE_PATH: 'dist'
-
SITE_NAME: 'my-react-site'
-
WISP_HANDLE: 'your-handle.bsky.social'
-
-
steps:
-
- name: build site
-
command: |
-
# necessary to ensure bun is in PATH
-
export PATH="$HOME/.nix-profile/bin:$PATH"
-
-
bun install --frozen-lockfile
-
-
# build with vite, run directly to get around env issues
-
bun node_modules/.bin/vite build
-
-
- name: deploy to wisp
-
command: |
-
# Download Wisp CLI
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
-
chmod +x wisp-cli
-
-
# Deploy to Wisp
-
./wisp-cli \\
-
"$WISP_HANDLE" \\
-
--path "$SITE_PATH" \\
-
--site "$SITE_NAME" \\
-
--password "$WISP_APP_PASSWORD"`}
-
language="yaml"
-
/>
-
</div>
-
</div>
-
-
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
-
<p className="text-xs text-muted-foreground">
-
<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.
-
Generate an app password from your AT Protocol account settings.
-
</p>
-
</div>
-
</div>
-
-
<div className="space-y-3">
-
<h3 className="text-sm font-semibold">Learn More</h3>
-
<div className="grid gap-2">
-
<a
-
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
-
>
-
<span className="text-sm">Source Code</span>
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
-
</a>
-
<a
-
href="https://blog.tangled.org/ci"
-
target="_blank"
-
rel="noopener noreferrer"
-
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
-
>
-
<span className="text-sm">Tangled Spindle CI/CD</span>
-
<ExternalLink className="w-4 h-4 text-muted-foreground" />
-
</a>
-
</div>
-
</div>
-
</CardContent>
-
</Card>
+
<TabsContent value="cli">
+
<CLITab />
</TabsContent>
</Tabs>
</div>
-
-
{/* Add Custom Domain Modal */}
-
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
-
<DialogContent className="sm:max-w-lg">
-
<DialogHeader>
-
<DialogTitle>Add Custom Domain</DialogTitle>
-
<DialogDescription>
-
Enter your domain name. After adding, you'll see the DNS
-
records to configure.
-
</DialogDescription>
-
</DialogHeader>
-
<div className="space-y-4 py-4">
-
<div className="space-y-2">
-
<Label htmlFor="new-domain">Domain Name</Label>
-
<Input
-
id="new-domain"
-
placeholder="example.com"
-
value={customDomain}
-
onChange={(e) => setCustomDomain(e.target.value)}
-
/>
-
<p className="text-xs text-muted-foreground">
-
After adding, click "View DNS" to see the records you
-
need to configure.
-
</p>
-
</div>
-
</div>
-
<DialogFooter className="flex-col sm:flex-row gap-2">
-
<Button
-
variant="outline"
-
onClick={() => {
-
setAddDomainModalOpen(false)
-
setCustomDomain('')
-
}}
-
className="w-full sm:w-auto"
-
disabled={isAddingDomain}
-
>
-
Cancel
-
</Button>
-
<Button
-
onClick={handleAddCustomDomain}
-
disabled={!customDomain || isAddingDomain}
-
className="w-full sm:w-auto"
-
>
-
{isAddingDomain ? (
-
<>
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
-
Adding...
-
</>
-
) : (
-
'Add Domain'
-
)}
-
</Button>
-
</DialogFooter>
-
</DialogContent>
-
</Dialog>
{/* Site Configuration Modal */}
<Dialog
···
)}
</Button>
</div>
-
</DialogFooter>
-
</DialogContent>
-
</Dialog>
-
-
{/* View DNS Records Modal */}
-
<Dialog
-
open={viewDomainDNS !== null}
-
onOpenChange={(open) => !open && setViewDomainDNS(null)}
-
>
-
<DialogContent className="sm:max-w-lg">
-
<DialogHeader>
-
<DialogTitle>DNS Configuration</DialogTitle>
-
<DialogDescription>
-
Add these DNS records to your domain provider
-
</DialogDescription>
-
</DialogHeader>
-
{viewDomainDNS && userInfo && (
-
<>
-
{(() => {
-
const domain = customDomains.find(
-
(d) => d.id === viewDomainDNS
-
)
-
if (!domain) return null
-
-
return (
-
<div className="space-y-4 py-4">
-
<div className="p-3 bg-muted/30 rounded-lg">
-
<p className="text-sm font-medium mb-1">
-
Domain:
-
</p>
-
<p className="font-mono text-sm">
-
{domain.domain}
-
</p>
-
</div>
-
-
<div className="space-y-3">
-
<div className="p-3 bg-background rounded border border-border">
-
<div className="flex justify-between items-start mb-2">
-
<span className="text-xs font-semibold text-muted-foreground">
-
TXT Record (Verification)
-
</span>
-
</div>
-
<div className="font-mono text-xs space-y-2">
-
<div>
-
<span className="text-muted-foreground">
-
Name:
-
</span>{' '}
-
<span className="select-all">
-
_wisp.{domain.domain}
-
</span>
-
</div>
-
<div>
-
<span className="text-muted-foreground">
-
Value:
-
</span>{' '}
-
<span className="select-all break-all">
-
{userInfo.did}
-
</span>
-
</div>
-
</div>
-
</div>
-
-
<div className="p-3 bg-background rounded border border-border">
-
<div className="flex justify-between items-start mb-2">
-
<span className="text-xs font-semibold text-muted-foreground">
-
CNAME Record (Pointing)
-
</span>
-
</div>
-
<div className="font-mono text-xs space-y-2">
-
<div>
-
<span className="text-muted-foreground">
-
Name:
-
</span>{' '}
-
<span className="select-all">
-
{domain.domain}
-
</span>
-
</div>
-
<div>
-
<span className="text-muted-foreground">
-
Value:
-
</span>{' '}
-
<span className="select-all">
-
{domain.id}.dns.wisp.place
-
</span>
-
</div>
-
</div>
-
<p className="text-xs text-muted-foreground mt-2">
-
Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification.
-
</p>
-
</div>
-
</div>
-
-
<div className="p-3 bg-muted/30 rounded-lg">
-
<p className="text-xs text-muted-foreground">
-
💡 After configuring DNS, click "Verify DNS"
-
to check if everything is set up correctly.
-
DNS changes can take a few minutes to
-
propagate.
-
</p>
-
</div>
-
</div>
-
)
-
})()}
-
</>
-
)}
-
<DialogFooter>
-
<Button
-
variant="outline"
-
onClick={() => setViewDomainDNS(null)}
-
className="w-full sm:w-auto"
-
>
-
Close
-
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
+212
public/editor/hooks/useDomainData.ts
···
+
import { useState } from 'react'
+
+
export interface CustomDomain {
+
id: string
+
domain: string
+
did: string
+
rkey: string
+
verified: boolean
+
last_verified_at: number | null
+
created_at: number
+
}
+
+
export interface WispDomain {
+
domain: string
+
rkey: string | null
+
}
+
+
type VerificationStatus = 'idle' | 'verifying' | 'success' | 'error'
+
+
export function useDomainData() {
+
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
+
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
+
const [domainsLoading, setDomainsLoading] = useState(true)
+
const [verificationStatus, setVerificationStatus] = useState<{
+
[id: string]: VerificationStatus
+
}>({})
+
+
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 addCustomDomain = async (domain: string) => {
+
try {
+
const response = await fetch('/api/domain/custom/add', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ domain })
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
await fetchDomains()
+
return { success: true, id: 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'}`
+
)
+
return { success: false }
+
}
+
}
+
+
const verifyDomain = 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 deleteCustomDomain = async (id: string) => {
+
if (!confirm('Are you sure you want to remove this custom domain?')) {
+
return false
+
}
+
+
try {
+
const response = await fetch(`/api/domain/custom/${id}`, {
+
method: 'DELETE'
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
await fetchDomains()
+
return true
+
} 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'}`
+
)
+
return false
+
}
+
}
+
+
const mapWispDomain = async (siteRkey: string | null) => {
+
try {
+
const response = await fetch('/api/domain/wisp/map-site', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ siteRkey })
+
})
+
const data = await response.json()
+
if (!data.success) throw new Error('Failed to map wisp domain')
+
return true
+
} catch (err) {
+
console.error('Map wisp domain error:', err)
+
throw err
+
}
+
}
+
+
const mapCustomDomain = async (domainId: string, siteRkey: string | null) => {
+
try {
+
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ siteRkey })
+
})
+
const data = await response.json()
+
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
+
return true
+
} catch (err) {
+
console.error('Map custom domain error:', err)
+
throw err
+
}
+
}
+
+
const claimWispDomain = async (handle: string) => {
+
try {
+
const response = await fetch('/api/domain/claim', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ handle })
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
await fetchDomains()
+
return { success: true }
+
} 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}`)
+
}
+
return { success: false, error: errorMessage }
+
}
+
}
+
+
const checkWispAvailability = async (handle: string) => {
+
const trimmedHandle = handle.trim().toLowerCase()
+
if (!trimmedHandle) {
+
return { available: null }
+
}
+
+
try {
+
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
+
const data = await response.json()
+
return { available: data.available }
+
} catch (err) {
+
console.error('Check availability error:', err)
+
return { available: false }
+
}
+
}
+
+
return {
+
wispDomain,
+
customDomains,
+
domainsLoading,
+
verificationStatus,
+
fetchDomains,
+
addCustomDomain,
+
verifyDomain,
+
deleteCustomDomain,
+
mapWispDomain,
+
mapCustomDomain,
+
claimWispDomain,
+
checkWispAvailability
+
}
+
}
+112
public/editor/hooks/useSiteData.ts
···
+
import { useState } from 'react'
+
+
export interface Site {
+
did: string
+
rkey: string
+
display_name: string | null
+
created_at: number
+
updated_at: number
+
}
+
+
export interface DomainInfo {
+
type: 'wisp' | 'custom'
+
domain: string
+
verified?: boolean
+
id?: string
+
}
+
+
export interface SiteWithDomains extends Site {
+
domains?: DomainInfo[]
+
}
+
+
export function useSiteData() {
+
const [sites, setSites] = useState<SiteWithDomains[]>([])
+
const [sitesLoading, setSitesLoading] = useState(true)
+
const [isSyncing, setIsSyncing] = useState(false)
+
+
const fetchSites = async () => {
+
try {
+
const response = await fetch('/api/user/sites')
+
const data = await response.json()
+
const sitesData: Site[] = data.sites || []
+
+
// Fetch domain info for each site
+
const sitesWithDomains = await Promise.all(
+
sitesData.map(async (site) => {
+
try {
+
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
+
const domainsData = await domainsResponse.json()
+
return {
+
...site,
+
domains: domainsData.domains || []
+
}
+
} catch (err) {
+
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
+
return {
+
...site,
+
domains: []
+
}
+
}
+
})
+
)
+
+
setSites(sitesWithDomains)
+
} 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 deleteSite = async (rkey: string) => {
+
try {
+
const response = await fetch(`/api/site/${rkey}`, {
+
method: 'DELETE'
+
})
+
+
const data = await response.json()
+
if (data.success) {
+
// Refresh sites list
+
await fetchSites()
+
return true
+
} 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'}`
+
)
+
return false
+
}
+
}
+
+
return {
+
sites,
+
sitesLoading,
+
isSyncing,
+
fetchSites,
+
syncSites,
+
deleteSite
+
}
+
}
+29
public/editor/hooks/useUserInfo.ts
···
+
import { useState } from 'react'
+
+
export interface UserInfo {
+
did: string
+
handle: string
+
}
+
+
export function useUserInfo() {
+
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
+
const [loading, setLoading] = useState(true)
+
+
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)
+
}
+
}
+
+
return {
+
userInfo,
+
loading,
+
fetchUserInfo
+
}
+
}
+18
public/editor/index.html
···
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elysia Static</title>
<link rel="icon" type="image/x-icon" href="../favicon.ico">
+
<style>
+
/* Dark theme fallback styles for before JS loads */
+
@media (prefers-color-scheme: dark) {
+
body {
+
background-color: oklch(0.23 0.015 285);
+
color: oklch(0.90 0.005 285);
+
}
+
+
pre {
+
background-color: oklch(0.33 0.015 285) !important;
+
color: oklch(0.90 0.005 285) !important;
+
}
+
+
.bg-muted {
+
background-color: oklch(0.33 0.015 285) !important;
+
}
+
}
+
</style>
</head>
<body>
<div id="elysia"></div>
+258
public/editor/tabs/CLITab.tsx
···
+
import {
+
Card,
+
CardContent,
+
CardDescription,
+
CardHeader,
+
CardTitle
+
} from '@public/components/ui/card'
+
import { Badge } from '@public/components/ui/badge'
+
import { ExternalLink } from 'lucide-react'
+
import { CodeBlock } from '@public/components/ui/code-block'
+
+
export function CLITab() {
+
return (
+
<div className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<div className="flex items-center gap-2 mb-2">
+
<CardTitle>Wisp CLI Tool</CardTitle>
+
<Badge variant="secondary" className="text-xs">v0.1.0</Badge>
+
<Badge variant="outline" className="text-xs">Alpha</Badge>
+
</div>
+
<CardDescription>
+
Deploy static sites directly from your terminal
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-6">
+
<div className="prose prose-sm max-w-none dark:prose-invert">
+
<p className="text-sm text-muted-foreground">
+
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.
+
</p>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Download CLI</h3>
+
<div className="grid gap-2">
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">macOS (Apple Silicon)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
+
</div>
+
</div>
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">Linux (ARM64)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
+
</div>
+
</div>
+
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
+
<a
+
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between mb-2"
+
>
+
<span className="font-mono text-sm">Linux (x86_64)</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<div className="text-xs text-muted-foreground">
+
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Basic Usage</h3>
+
<CodeBlock
+
code={`# Download and make executable
+
curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64
+
chmod +x wisp-cli-macos-arm64
+
+
# Deploy your site (will use OAuth)
+
./wisp-cli-macos-arm64 your-handle.bsky.social \\
+
--path ./dist \\
+
--site my-site
+
+
# Your site will be available at:
+
# https://sites.wisp.place/your-handle/my-site`}
+
language="bash"
+
/>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3>
+
<p className="text-xs text-muted-foreground">
+
Deploy automatically on every push using{' '}
+
<a
+
href="https://blog.tangled.org/ci"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-accent hover:underline"
+
>
+
Tangled Spindle
+
</a>
+
</p>
+
+
<div className="space-y-4">
+
<div>
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
+
<span>Example 1: Simple Asset Publishing</span>
+
<Badge variant="secondary" className="text-xs">Copy Files</Badge>
+
</h4>
+
<CodeBlock
+
code={`when:
+
- event: ['push']
+
branch: ['main']
+
- event: ['manual']
+
+
engine: 'nixery'
+
+
clone:
+
skip: false
+
depth: 1
+
+
dependencies:
+
nixpkgs:
+
- coreutils
+
- curl
+
+
environment:
+
SITE_PATH: '.' # Copy entire repo
+
SITE_NAME: 'myWebbedSite'
+
WISP_HANDLE: 'your-handle.bsky.social'
+
+
steps:
+
- name: deploy assets to wisp
+
command: |
+
# Download Wisp CLI
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
+
chmod +x wisp-cli
+
+
# Deploy to Wisp
+
./wisp-cli \\
+
"$WISP_HANDLE" \\
+
--path "$SITE_PATH" \\
+
--site "$SITE_NAME" \\
+
--password "$WISP_APP_PASSWORD"
+
+
# Output
+
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
+
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
+
`}
+
language="yaml"
+
/>
+
</div>
+
+
<div>
+
<h4 className="text-xs font-semibold mb-2 flex items-center gap-2">
+
<span>Example 2: React/Vite Build & Deploy</span>
+
<Badge variant="secondary" className="text-xs">Full Build</Badge>
+
</h4>
+
<CodeBlock
+
code={`when:
+
- event: ['push']
+
branch: ['main']
+
- event: ['manual']
+
+
engine: 'nixery'
+
+
clone:
+
skip: false
+
depth: 1
+
submodules: false
+
+
dependencies:
+
nixpkgs:
+
- nodejs
+
- coreutils
+
- curl
+
github:NixOS/nixpkgs/nixpkgs-unstable:
+
- bun
+
+
environment:
+
SITE_PATH: 'dist'
+
SITE_NAME: 'my-react-site'
+
WISP_HANDLE: 'your-handle.bsky.social'
+
+
steps:
+
- name: build site
+
command: |
+
# necessary to ensure bun is in PATH
+
export PATH="$HOME/.nix-profile/bin:$PATH"
+
+
bun install --frozen-lockfile
+
+
# build with vite, run directly to get around env issues
+
bun node_modules/.bin/vite build
+
+
- name: deploy to wisp
+
command: |
+
# Download Wisp CLI
+
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
+
chmod +x wisp-cli
+
+
# Deploy to Wisp
+
./wisp-cli \\
+
"$WISP_HANDLE" \\
+
--path "$SITE_PATH" \\
+
--site "$SITE_NAME" \\
+
--password "$WISP_APP_PASSWORD"`}
+
language="yaml"
+
/>
+
</div>
+
</div>
+
+
<div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent">
+
<p className="text-xs text-muted-foreground">
+
<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.
+
Generate an app password from your AT Protocol account settings.
+
</p>
+
</div>
+
</div>
+
+
<div className="space-y-3">
+
<h3 className="text-sm font-semibold">Learn More</h3>
+
<div className="grid gap-2">
+
<a
+
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
+
>
+
<span className="text-sm">Source Code</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
<a
+
href="https://blog.tangled.org/ci"
+
target="_blank"
+
rel="noopener noreferrer"
+
className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"
+
>
+
<span className="text-sm">Tangled Spindle CI/CD</span>
+
<ExternalLink className="w-4 h-4 text-muted-foreground" />
+
</a>
+
</div>
+
</div>
+
</CardContent>
+
</Card>
+
</div>
+
)
+
}
+499
public/editor/tabs/DomainsTab.tsx
···
+
import { useState } from 'react'
+
import {
+
Card,
+
CardContent,
+
CardDescription,
+
CardHeader,
+
CardTitle
+
} from '@public/components/ui/card'
+
import { Button } from '@public/components/ui/button'
+
import { Input } from '@public/components/ui/input'
+
import { Label } from '@public/components/ui/label'
+
import { Badge } from '@public/components/ui/badge'
+
import {
+
Dialog,
+
DialogContent,
+
DialogDescription,
+
DialogHeader,
+
DialogTitle,
+
DialogFooter
+
} from '@public/components/ui/dialog'
+
import {
+
CheckCircle2,
+
XCircle,
+
Loader2,
+
Trash2
+
} from 'lucide-react'
+
import type { WispDomain, CustomDomain } from '../hooks/useDomainData'
+
import type { UserInfo } from '../hooks/useUserInfo'
+
+
interface DomainsTabProps {
+
wispDomain: WispDomain | null
+
customDomains: CustomDomain[]
+
domainsLoading: boolean
+
verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' }
+
userInfo: UserInfo | null
+
onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }>
+
onVerifyDomain: (id: string) => Promise<void>
+
onDeleteCustomDomain: (id: string) => Promise<boolean>
+
onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }>
+
onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }>
+
}
+
+
export function DomainsTab({
+
wispDomain,
+
customDomains,
+
domainsLoading,
+
verificationStatus,
+
userInfo,
+
onAddCustomDomain,
+
onVerifyDomain,
+
onDeleteCustomDomain,
+
onClaimWispDomain,
+
onCheckWispAvailability
+
}: DomainsTabProps) {
+
// 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 })
+
+
// Custom domain modal state
+
const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
+
const [customDomain, setCustomDomain] = useState('')
+
const [isAddingDomain, setIsAddingDomain] = useState(false)
+
const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
+
+
const checkWispAvailability = async (handle: string) => {
+
const trimmedHandle = handle.trim().toLowerCase()
+
if (!trimmedHandle) {
+
setWispAvailability({ available: null, checking: false })
+
return
+
}
+
+
setWispAvailability({ available: null, checking: true })
+
const result = await onCheckWispAvailability(trimmedHandle)
+
setWispAvailability({ available: result.available, checking: false })
+
}
+
+
const handleClaimWispDomain = async () => {
+
const trimmedHandle = wispHandle.trim().toLowerCase()
+
if (!trimmedHandle) {
+
alert('Please enter a handle')
+
return
+
}
+
+
setIsClaimingWisp(true)
+
const result = await onClaimWispDomain(trimmedHandle)
+
if (result.success) {
+
setWispHandle('')
+
setWispAvailability({ available: null, checking: false })
+
}
+
setIsClaimingWisp(false)
+
}
+
+
const handleAddCustomDomain = async () => {
+
if (!customDomain) {
+
alert('Please enter a domain')
+
return
+
}
+
+
setIsAddingDomain(true)
+
const result = await onAddCustomDomain(customDomain)
+
setIsAddingDomain(false)
+
+
if (result.success) {
+
setCustomDomain('')
+
setAddDomainModalOpen(false)
+
// Automatically show DNS configuration for the newly added domain
+
if (result.id) {
+
setViewDomainDNS(result.id)
+
}
+
}
+
}
+
+
return (
+
<>
+
<div className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<CardTitle>wisp.place Subdomain</CardTitle>
+
<CardDescription>
+
Your free subdomain on the wisp.place network
+
</CardDescription>
+
</CardHeader>
+
<CardContent>
+
{domainsLoading ? (
+
<div className="flex items-center justify-center py-4">
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
+
</div>
+
) : wispDomain ? (
+
<>
+
<div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
+
<div className="flex items-center gap-2">
+
<CheckCircle2 className="w-5 h-5 text-green-500" />
+
<span className="font-mono text-lg">
+
{wispDomain.domain}
+
</span>
+
</div>
+
{wispDomain.rkey && (
+
<p className="text-xs text-muted-foreground ml-7">
+
→ Mapped to site: {wispDomain.rkey}
+
</p>
+
)}
+
</div>
+
<p className="text-sm text-muted-foreground mt-3">
+
{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.'}
+
</p>
+
</>
+
) : (
+
<div className="space-y-4">
+
<div className="p-4 bg-muted/30 rounded-lg">
+
<p className="text-sm text-muted-foreground mb-4">
+
Claim your free wisp.place subdomain
+
</p>
+
<div className="space-y-3">
+
<div className="space-y-2">
+
<Label htmlFor="wisp-handle">Choose your handle</Label>
+
<div className="flex gap-2">
+
<div className="flex-1 relative">
+
<Input
+
id="wisp-handle"
+
placeholder="mysite"
+
value={wispHandle}
+
onChange={(e) => {
+
setWispHandle(e.target.value)
+
if (e.target.value.trim()) {
+
checkWispAvailability(e.target.value)
+
} else {
+
setWispAvailability({ available: null, checking: false })
+
}
+
}}
+
disabled={isClaimingWisp}
+
className="pr-24"
+
/>
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
+
.wisp.place
+
</span>
+
</div>
+
</div>
+
{wispAvailability.checking && (
+
<p className="text-xs text-muted-foreground flex items-center gap-1">
+
<Loader2 className="w-3 h-3 animate-spin" />
+
Checking availability...
+
</p>
+
)}
+
{!wispAvailability.checking && wispAvailability.available === true && (
+
<p className="text-xs text-green-600 flex items-center gap-1">
+
<CheckCircle2 className="w-3 h-3" />
+
Available
+
</p>
+
)}
+
{!wispAvailability.checking && wispAvailability.available === false && (
+
<p className="text-xs text-red-600 flex items-center gap-1">
+
<XCircle className="w-3 h-3" />
+
Not available
+
</p>
+
)}
+
</div>
+
<Button
+
onClick={handleClaimWispDomain}
+
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
+
className="w-full"
+
>
+
{isClaimingWisp ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Claiming...
+
</>
+
) : (
+
'Claim Subdomain'
+
)}
+
</Button>
+
</div>
+
</div>
+
</div>
+
)}
+
</CardContent>
+
</Card>
+
+
<Card>
+
<CardHeader>
+
<CardTitle>Custom Domains</CardTitle>
+
<CardDescription>
+
Bring your own domain with DNS verification
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-4">
+
<Button
+
onClick={() => setAddDomainModalOpen(true)}
+
className="w-full"
+
>
+
Add Custom Domain
+
</Button>
+
+
{domainsLoading ? (
+
<div className="flex items-center justify-center py-4">
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
+
</div>
+
) : customDomains.length === 0 ? (
+
<div className="text-center py-4 text-muted-foreground text-sm">
+
No custom domains added yet
+
</div>
+
) : (
+
<div className="space-y-2">
+
{customDomains.map((domain) => (
+
<div
+
key={domain.id}
+
className="flex items-center justify-between p-3 border border-border rounded-lg"
+
>
+
<div className="flex flex-col gap-1 flex-1">
+
<div className="flex items-center gap-2">
+
{domain.verified ? (
+
<CheckCircle2 className="w-4 h-4 text-green-500" />
+
) : (
+
<XCircle className="w-4 h-4 text-red-500" />
+
)}
+
<span className="font-mono">
+
{domain.domain}
+
</span>
+
</div>
+
{domain.rkey && domain.rkey !== 'self' && (
+
<p className="text-xs text-muted-foreground ml-6">
+
→ Mapped to site: {domain.rkey}
+
</p>
+
)}
+
</div>
+
<div className="flex items-center gap-2">
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() =>
+
setViewDomainDNS(domain.id)
+
}
+
>
+
View DNS
+
</Button>
+
{domain.verified ? (
+
<Badge variant="secondary">
+
Verified
+
</Badge>
+
) : (
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() =>
+
onVerifyDomain(domain.id)
+
}
+
disabled={
+
verificationStatus[
+
domain.id
+
] === 'verifying'
+
}
+
>
+
{verificationStatus[
+
domain.id
+
] === 'verifying' ? (
+
<>
+
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
+
Verifying...
+
</>
+
) : (
+
'Verify DNS'
+
)}
+
</Button>
+
)}
+
<Button
+
variant="ghost"
+
size="sm"
+
onClick={() =>
+
onDeleteCustomDomain(
+
domain.id
+
)
+
}
+
>
+
<Trash2 className="w-4 h-4" />
+
</Button>
+
</div>
+
</div>
+
))}
+
</div>
+
)}
+
</CardContent>
+
</Card>
+
</div>
+
+
{/* Add Custom Domain Modal */}
+
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
+
<DialogContent className="sm:max-w-lg">
+
<DialogHeader>
+
<DialogTitle>Add Custom Domain</DialogTitle>
+
<DialogDescription>
+
Enter your domain name. After adding, you'll see the DNS
+
records to configure.
+
</DialogDescription>
+
</DialogHeader>
+
<div className="space-y-4 py-4">
+
<div className="space-y-2">
+
<Label htmlFor="new-domain">Domain Name</Label>
+
<Input
+
id="new-domain"
+
placeholder="example.com"
+
value={customDomain}
+
onChange={(e) => setCustomDomain(e.target.value)}
+
/>
+
<p className="text-xs text-muted-foreground">
+
After adding, click "View DNS" to see the records you
+
need to configure.
+
</p>
+
</div>
+
</div>
+
<DialogFooter className="flex-col sm:flex-row gap-2">
+
<Button
+
variant="outline"
+
onClick={() => {
+
setAddDomainModalOpen(false)
+
setCustomDomain('')
+
}}
+
className="w-full sm:w-auto"
+
disabled={isAddingDomain}
+
>
+
Cancel
+
</Button>
+
<Button
+
onClick={handleAddCustomDomain}
+
disabled={!customDomain || isAddingDomain}
+
className="w-full sm:w-auto"
+
>
+
{isAddingDomain ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Adding...
+
</>
+
) : (
+
'Add Domain'
+
)}
+
</Button>
+
</DialogFooter>
+
</DialogContent>
+
</Dialog>
+
+
{/* View DNS Records Modal */}
+
<Dialog
+
open={viewDomainDNS !== null}
+
onOpenChange={(open) => !open && setViewDomainDNS(null)}
+
>
+
<DialogContent className="sm:max-w-lg">
+
<DialogHeader>
+
<DialogTitle>DNS Configuration</DialogTitle>
+
<DialogDescription>
+
Add these DNS records to your domain provider
+
</DialogDescription>
+
</DialogHeader>
+
{viewDomainDNS && userInfo && (
+
<>
+
{(() => {
+
const domain = customDomains.find(
+
(d) => d.id === viewDomainDNS
+
)
+
if (!domain) return null
+
+
return (
+
<div className="space-y-4 py-4">
+
<div className="p-3 bg-muted/30 rounded-lg">
+
<p className="text-sm font-medium mb-1">
+
Domain:
+
</p>
+
<p className="font-mono text-sm">
+
{domain.domain}
+
</p>
+
</div>
+
+
<div className="space-y-3">
+
<div className="p-3 bg-background rounded border border-border">
+
<div className="flex justify-between items-start mb-2">
+
<span className="text-xs font-semibold text-muted-foreground">
+
TXT Record (Verification)
+
</span>
+
</div>
+
<div className="font-mono text-xs space-y-2">
+
<div>
+
<span className="text-muted-foreground">
+
Name:
+
</span>{' '}
+
<span className="select-all">
+
_wisp.{domain.domain}
+
</span>
+
</div>
+
<div>
+
<span className="text-muted-foreground">
+
Value:
+
</span>{' '}
+
<span className="select-all break-all">
+
{userInfo.did}
+
</span>
+
</div>
+
</div>
+
</div>
+
+
<div className="p-3 bg-background rounded border border-border">
+
<div className="flex justify-between items-start mb-2">
+
<span className="text-xs font-semibold text-muted-foreground">
+
CNAME Record (Pointing)
+
</span>
+
</div>
+
<div className="font-mono text-xs space-y-2">
+
<div>
+
<span className="text-muted-foreground">
+
Name:
+
</span>{' '}
+
<span className="select-all">
+
{domain.domain}
+
</span>
+
</div>
+
<div>
+
<span className="text-muted-foreground">
+
Value:
+
</span>{' '}
+
<span className="select-all">
+
{domain.id}.dns.wisp.place
+
</span>
+
</div>
+
</div>
+
<p className="text-xs text-muted-foreground mt-2">
+
Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification.
+
</p>
+
</div>
+
</div>
+
+
<div className="p-3 bg-muted/30 rounded-lg">
+
<p className="text-xs text-muted-foreground">
+
💡 After configuring DNS, click "Verify DNS"
+
to check if everything is set up correctly.
+
DNS changes can take a few minutes to
+
propagate.
+
</p>
+
</div>
+
</div>
+
)
+
})()}
+
</>
+
)}
+
<DialogFooter>
+
<Button
+
variant="outline"
+
onClick={() => setViewDomainDNS(null)}
+
className="w-full sm:w-auto"
+
>
+
Close
+
</Button>
+
</DialogFooter>
+
</DialogContent>
+
</Dialog>
+
</>
+
)
+
}
+196
public/editor/tabs/SitesTab.tsx
···
+
import {
+
Card,
+
CardContent,
+
CardDescription,
+
CardHeader,
+
CardTitle
+
} from '@public/components/ui/card'
+
import { Button } from '@public/components/ui/button'
+
import { Badge } from '@public/components/ui/badge'
+
import {
+
Globe,
+
ExternalLink,
+
CheckCircle2,
+
AlertCircle,
+
Loader2,
+
RefreshCw,
+
Settings
+
} from 'lucide-react'
+
import type { SiteWithDomains } from '../hooks/useSiteData'
+
import type { UserInfo } from '../hooks/useUserInfo'
+
+
interface SitesTabProps {
+
sites: SiteWithDomains[]
+
sitesLoading: boolean
+
isSyncing: boolean
+
userInfo: UserInfo | null
+
onSyncSites: () => Promise<void>
+
onConfigureSite: (site: SiteWithDomains) => void
+
}
+
+
export function SitesTab({
+
sites,
+
sitesLoading,
+
isSyncing,
+
userInfo,
+
onSyncSites,
+
onConfigureSite
+
}: SitesTabProps) {
+
const getSiteUrl = (site: SiteWithDomains) => {
+
// Use the first mapped domain if available
+
if (site.domains && site.domains.length > 0) {
+
return `https://${site.domains[0].domain}`
+
}
+
+
// Default fallback URL - use handle instead of DID
+
if (!userInfo) return '#'
+
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
+
}
+
+
const getSiteDomainName = (site: SiteWithDomains) => {
+
// Return the first domain if available
+
if (site.domains && site.domains.length > 0) {
+
return site.domains[0].domain
+
}
+
+
// Use handle instead of DID for display
+
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
+
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
+
}
+
+
return (
+
<div className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<div className="flex items-center justify-between">
+
<div>
+
<CardTitle>Your Sites</CardTitle>
+
<CardDescription>
+
View and manage all your deployed sites
+
</CardDescription>
+
</div>
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={onSyncSites}
+
disabled={isSyncing || sitesLoading}
+
>
+
<RefreshCw
+
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
+
/>
+
Sync from PDS
+
</Button>
+
</div>
+
</CardHeader>
+
<CardContent className="space-y-4">
+
{sitesLoading ? (
+
<div className="flex items-center justify-center py-8">
+
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
+
</div>
+
) : sites.length === 0 ? (
+
<div className="text-center py-8 text-muted-foreground">
+
<p>No sites yet. Upload your first site!</p>
+
</div>
+
) : (
+
sites.map((site) => (
+
<div
+
key={`${site.did}-${site.rkey}`}
+
className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
+
>
+
<div className="flex-1">
+
<div className="flex items-center gap-3 mb-2">
+
<h3 className="font-semibold text-lg">
+
{site.display_name || site.rkey}
+
</h3>
+
<Badge
+
variant="secondary"
+
className="text-xs"
+
>
+
active
+
</Badge>
+
</div>
+
+
{/* Display all mapped domains */}
+
{site.domains && site.domains.length > 0 ? (
+
<div className="space-y-1">
+
{site.domains.map((domainInfo, idx) => (
+
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
+
<a
+
href={`https://${domainInfo.domain}`}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
+
>
+
<Globe className="w-3 h-3" />
+
{domainInfo.domain}
+
<ExternalLink className="w-3 h-3" />
+
</a>
+
<Badge
+
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
+
className="text-xs"
+
>
+
{domainInfo.type}
+
</Badge>
+
{domainInfo.type === 'custom' && (
+
<Badge
+
variant={domainInfo.verified ? 'default' : 'secondary'}
+
className="text-xs"
+
>
+
{domainInfo.verified ? (
+
<>
+
<CheckCircle2 className="w-3 h-3 mr-1" />
+
verified
+
</>
+
) : (
+
<>
+
<AlertCircle className="w-3 h-3 mr-1" />
+
pending
+
</>
+
)}
+
</Badge>
+
)}
+
</div>
+
))}
+
</div>
+
) : (
+
<a
+
href={getSiteUrl(site)}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
+
>
+
{getSiteDomainName(site)}
+
<ExternalLink className="w-3 h-3" />
+
</a>
+
)}
+
</div>
+
<Button
+
variant="outline"
+
size="sm"
+
onClick={() => onConfigureSite(site)}
+
>
+
<Settings className="w-4 h-4 mr-2" />
+
Configure
+
</Button>
+
</div>
+
))
+
)}
+
</CardContent>
+
</Card>
+
+
<div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
+
<div className="flex items-start gap-2">
+
<AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
+
<div className="flex-1 space-y-1">
+
<p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
+
Note about sites.wisp.place URLs
+
</p>
+
<p className="text-xs text-muted-foreground">
+
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.
+
</p>
+
</div>
+
</div>
+
</div>
+
</div>
+
)
+
}
+494
public/editor/tabs/UploadTab.tsx
···
+
import { useState, useEffect } from 'react'
+
import {
+
Card,
+
CardContent,
+
CardDescription,
+
CardHeader,
+
CardTitle
+
} from '@public/components/ui/card'
+
import { Button } from '@public/components/ui/button'
+
import { Input } from '@public/components/ui/input'
+
import { Label } from '@public/components/ui/label'
+
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
+
import { Badge } from '@public/components/ui/badge'
+
import {
+
Globe,
+
Upload,
+
AlertCircle,
+
Loader2
+
} from 'lucide-react'
+
import type { SiteWithDomains } from '../hooks/useSiteData'
+
+
interface UploadTabProps {
+
sites: SiteWithDomains[]
+
sitesLoading: boolean
+
onUploadComplete: () => Promise<void>
+
}
+
+
// Batching configuration
+
const BATCH_SIZE = 15 // files per batch
+
const CONCURRENT_BATCHES = 3 // parallel batches
+
const MAX_RETRIES = 2 // retry attempts per file
+
+
interface BatchProgress {
+
total: number
+
uploaded: number
+
failed: number
+
current: number
+
}
+
+
export function UploadTab({
+
sites,
+
sitesLoading,
+
onUploadComplete
+
}: UploadTabProps) {
+
// Upload state
+
const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing')
+
const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('')
+
const [newSiteName, setNewSiteName] = useState('')
+
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
+
const [isUploading, setIsUploading] = useState(false)
+
const [uploadProgress, setUploadProgress] = useState('')
+
const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([])
+
const [uploadedCount, setUploadedCount] = useState(0)
+
const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null)
+
+
// Auto-switch to 'new' mode if no sites exist
+
useEffect(() => {
+
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
+
setSiteMode('new')
+
}
+
}, [sites, sitesLoading, siteMode])
+
+
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+
if (e.target.files && e.target.files.length > 0) {
+
setSelectedFiles(e.target.files)
+
}
+
}
+
+
// Split files into batches
+
const createBatches = (files: FileList): File[][] => {
+
const batches: File[][] = []
+
const fileArray = Array.from(files)
+
+
for (let i = 0; i < fileArray.length; i += BATCH_SIZE) {
+
batches.push(fileArray.slice(i, i + BATCH_SIZE))
+
}
+
+
return batches
+
}
+
+
// Upload a single file with retry logic
+
const uploadFileWithRetry = async (
+
file: File,
+
retries: number = MAX_RETRIES
+
): Promise<{ success: boolean; error?: string }> => {
+
for (let attempt = 0; attempt <= retries; attempt++) {
+
try {
+
// Simulate file validation (would normally happen on server)
+
// Return success (actual upload happens in batch)
+
return { success: true }
+
} catch (err) {
+
// Check if error is retryable
+
const error = err as any
+
const statusCode = error?.response?.status
+
+
// Don't retry for client errors (4xx except timeouts)
+
if (statusCode === 413 || statusCode === 400) {
+
return {
+
success: false,
+
error: statusCode === 413 ? 'File too large' : 'Validation error'
+
}
+
}
+
+
// If this was the last attempt, fail
+
if (attempt === retries) {
+
return {
+
success: false,
+
error: err instanceof Error ? err.message : 'Upload failed'
+
}
+
}
+
+
// Wait before retry (exponential backoff)
+
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)))
+
}
+
}
+
+
return { success: false, error: 'Max retries exceeded' }
+
}
+
+
// Process a single batch
+
const processBatch = async (
+
batch: File[],
+
batchIndex: number,
+
totalBatches: number,
+
formData: FormData
+
): Promise<{ succeeded: File[]; failed: Array<{ file: File; reason: string }> }> => {
+
const succeeded: File[] = []
+
const failed: Array<{ file: File; reason: string }> = []
+
+
setUploadProgress(`Processing batch ${batchIndex + 1}/${totalBatches} (files ${batchIndex * BATCH_SIZE + 1}-${Math.min((batchIndex + 1) * BATCH_SIZE, formData.getAll('files').length)})...`)
+
+
// Process files in batch with retry logic
+
const results = await Promise.allSettled(
+
batch.map(file => uploadFileWithRetry(file))
+
)
+
+
results.forEach((result, idx) => {
+
if (result.status === 'fulfilled' && result.value.success) {
+
succeeded.push(batch[idx])
+
} else {
+
const reason = result.status === 'rejected'
+
? 'Upload failed'
+
: result.value.error || 'Unknown error'
+
failed.push({ file: batch[idx], reason })
+
}
+
})
+
+
return { succeeded, failed }
+
}
+
+
// Main upload handler with batching
+
const handleUpload = async () => {
+
const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName
+
+
if (!siteName) {
+
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
+
return
+
}
+
+
if (!selectedFiles || selectedFiles.length === 0) {
+
alert('Please select files to upload')
+
return
+
}
+
+
setIsUploading(true)
+
setUploadProgress('Preparing files...')
+
setSkippedFiles([])
+
setUploadedCount(0)
+
+
try {
+
const formData = new FormData()
+
formData.append('siteName', siteName)
+
+
// Add all files to FormData
+
for (let i = 0; i < selectedFiles.length; i++) {
+
formData.append('files', selectedFiles[i])
+
}
+
+
const totalFiles = selectedFiles.length
+
const batches = createBatches(selectedFiles)
+
const totalBatches = batches.length
+
+
console.log(`Uploading ${totalFiles} files in ${totalBatches} batches (${BATCH_SIZE} files per batch, ${CONCURRENT_BATCHES} concurrent)`)
+
+
// Initialize batch progress
+
setBatchProgress({
+
total: totalFiles,
+
uploaded: 0,
+
failed: 0,
+
current: 0
+
})
+
+
// Process batches with concurrency limit
+
const allSkipped: Array<{ name: string; reason: string }> = []
+
let totalUploaded = 0
+
+
for (let i = 0; i < batches.length; i += CONCURRENT_BATCHES) {
+
const batchSlice = batches.slice(i, i + CONCURRENT_BATCHES)
+
const batchPromises = batchSlice.map((batch, idx) =>
+
processBatch(batch, i + idx, totalBatches, formData)
+
)
+
+
const results = await Promise.all(batchPromises)
+
+
// Aggregate results
+
results.forEach(result => {
+
totalUploaded += result.succeeded.length
+
result.failed.forEach(({ file, reason }) => {
+
allSkipped.push({ name: file.name, reason })
+
})
+
})
+
+
// Update progress
+
setBatchProgress({
+
total: totalFiles,
+
uploaded: totalUploaded,
+
failed: allSkipped.length,
+
current: Math.min((i + CONCURRENT_BATCHES) * BATCH_SIZE, totalFiles)
+
})
+
}
+
+
// Now send the actual upload request to the server
+
// (In a real implementation, you'd send batches to the server,
+
// but for compatibility with the existing API, we send all at once)
+
setUploadProgress('Finalizing upload 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 || allSkipped)
+
setUploadedCount(data.uploadedCount || data.fileCount || totalUploaded)
+
setSelectedSiteRkey('')
+
setNewSiteName('')
+
setSelectedFiles(null)
+
+
// Refresh sites list
+
await onUploadComplete()
+
+
// Reset form - give more time if there are skipped files
+
const resetDelay = (data.skippedFiles && data.skippedFiles.length > 0) || allSkipped.length > 0 ? 4000 : 1500
+
setTimeout(() => {
+
setUploadProgress('')
+
setSkippedFiles([])
+
setUploadedCount(0)
+
setBatchProgress(null)
+
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('')
+
setBatchProgress(null)
+
}
+
}
+
+
return (
+
<div className="space-y-4 min-h-[400px]">
+
<Card>
+
<CardHeader>
+
<CardTitle>Upload Site</CardTitle>
+
<CardDescription>
+
Deploy a new site from a folder or Git repository
+
</CardDescription>
+
</CardHeader>
+
<CardContent className="space-y-6">
+
<div className="space-y-4">
+
<div className="p-4 bg-muted/50 rounded-lg">
+
<RadioGroup
+
value={siteMode}
+
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
+
disabled={isUploading}
+
>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="existing" id="existing" />
+
<Label htmlFor="existing" className="cursor-pointer">
+
Update existing site
+
</Label>
+
</div>
+
<div className="flex items-center space-x-2">
+
<RadioGroupItem value="new" id="new" />
+
<Label htmlFor="new" className="cursor-pointer">
+
Create new site
+
</Label>
+
</div>
+
</RadioGroup>
+
</div>
+
+
{siteMode === 'existing' ? (
+
<div className="space-y-2">
+
<Label htmlFor="site-select">Select Site</Label>
+
{sitesLoading ? (
+
<div className="flex items-center justify-center py-4">
+
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
+
</div>
+
) : sites.length === 0 ? (
+
<div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground">
+
No sites available. Create a new site instead.
+
</div>
+
) : (
+
<select
+
id="site-select"
+
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"
+
value={selectedSiteRkey}
+
onChange={(e) => setSelectedSiteRkey(e.target.value)}
+
disabled={isUploading}
+
>
+
<option value="">Select a site...</option>
+
{sites.map((site) => (
+
<option key={site.rkey} value={site.rkey}>
+
{site.display_name || site.rkey}
+
</option>
+
))}
+
</select>
+
)}
+
</div>
+
) : (
+
<div className="space-y-2">
+
<Label htmlFor="new-site-name">New Site Name</Label>
+
<Input
+
id="new-site-name"
+
placeholder="my-awesome-site"
+
value={newSiteName}
+
onChange={(e) => setNewSiteName(e.target.value)}
+
disabled={isUploading}
+
/>
+
</div>
+
)}
+
+
<p className="text-xs text-muted-foreground">
+
File limits: 100MB per file, 300MB total
+
</p>
+
</div>
+
+
<div className="grid md:grid-cols-2 gap-4">
+
<Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
+
<Upload className="w-12 h-12 text-muted-foreground mb-4" />
+
<h3 className="font-semibold mb-2">
+
Upload Folder
+
</h3>
+
<p className="text-sm text-muted-foreground mb-4">
+
Drag and drop or click to upload your
+
static site files
+
</p>
+
<input
+
type="file"
+
id="file-upload"
+
multiple
+
onChange={handleFileSelect}
+
className="hidden"
+
{...(({ webkitdirectory: '', directory: '' } as any))}
+
disabled={isUploading}
+
/>
+
<label htmlFor="file-upload">
+
<Button
+
variant="outline"
+
type="button"
+
onClick={() =>
+
document
+
.getElementById('file-upload')
+
?.click()
+
}
+
disabled={isUploading}
+
>
+
Choose Folder
+
</Button>
+
</label>
+
{selectedFiles && selectedFiles.length > 0 && (
+
<p className="text-sm text-muted-foreground mt-3">
+
{selectedFiles.length} files selected
+
</p>
+
)}
+
</CardContent>
+
</Card>
+
+
<Card className="border-2 border-dashed opacity-50">
+
<CardContent className="flex flex-col items-center justify-center p-8 text-center">
+
<Globe className="w-12 h-12 text-muted-foreground mb-4" />
+
<h3 className="font-semibold mb-2">
+
Connect Git Repository
+
</h3>
+
<p className="text-sm text-muted-foreground mb-4">
+
Link your GitHub, GitLab, or any Git
+
repository
+
</p>
+
<Badge variant="secondary">Coming soon!</Badge>
+
</CardContent>
+
</Card>
+
</div>
+
+
{uploadProgress && (
+
<div className="space-y-3">
+
<div className="p-4 bg-muted rounded-lg">
+
<div className="flex items-center gap-2 mb-2">
+
<Loader2 className="w-4 h-4 animate-spin" />
+
<span className="text-sm">{uploadProgress}</span>
+
</div>
+
{batchProgress && (
+
<div className="mt-2 space-y-1">
+
<div className="flex items-center justify-between text-xs text-muted-foreground">
+
<span>
+
Uploaded: {batchProgress.uploaded}/{batchProgress.total}
+
</span>
+
<span>
+
Failed: {batchProgress.failed}
+
</span>
+
</div>
+
<div className="w-full bg-muted-foreground/20 rounded-full h-2">
+
<div
+
className="bg-accent h-2 rounded-full transition-all duration-300"
+
style={{
+
width: `${(batchProgress.uploaded / batchProgress.total) * 100}%`
+
}}
+
/>
+
</div>
+
</div>
+
)}
+
</div>
+
+
{skippedFiles.length > 0 && (
+
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
+
<div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2">
+
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
+
<div className="flex-1">
+
<span className="font-medium">
+
{skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped
+
</span>
+
{uploadedCount > 0 && (
+
<span className="text-sm ml-2">
+
({uploadedCount} uploaded successfully)
+
</span>
+
)}
+
</div>
+
</div>
+
<div className="ml-6 space-y-1 max-h-32 overflow-y-auto">
+
{skippedFiles.slice(0, 5).map((file, idx) => (
+
<div key={idx} className="text-xs">
+
<span className="font-mono">{file.name}</span>
+
<span className="text-muted-foreground"> - {file.reason}</span>
+
</div>
+
))}
+
{skippedFiles.length > 5 && (
+
<div className="text-xs text-muted-foreground">
+
...and {skippedFiles.length - 5} more
+
</div>
+
)}
+
</div>
+
</div>
+
)}
+
</div>
+
)}
+
+
<Button
+
onClick={handleUpload}
+
className="w-full"
+
disabled={
+
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
+
isUploading ||
+
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
+
}
+
>
+
{isUploading ? (
+
<>
+
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
+
Uploading...
+
</>
+
) : (
+
<>
+
{siteMode === 'existing' ? (
+
'Update Site'
+
) : (
+
selectedFiles && selectedFiles.length > 0
+
? 'Upload & Deploy'
+
: 'Create Empty Site'
+
)}
+
</>
+
)}
+
</Button>
+
</CardContent>
+
</Card>
+
</div>
+
)
+
}