diff --git a/public/editor/components/TabSkeleton.tsx b/public/editor/components/TabSkeleton.tsx new file mode 100644 index 0000000..69c7418 --- /dev/null +++ b/public/editor/components/TabSkeleton.tsx @@ -0,0 +1,65 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@public/components/ui/card' + +// Shimmer animation for skeleton loading +const Shimmer = () => ( +
+
+
+
+) + +const SkeletonLine = ({ className = '' }: { className?: string }) => ( +
+) + +export function TabSkeleton() { + return ( +
+ + +
+ + +
+
+ + {/* Skeleton content items */} +
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + +
+ + +
+
+ + + + +
+
+ ) +} diff --git a/public/editor/editor.tsx b/public/editor/editor.tsx index 359f1af..546cf7a 100644 --- a/public/editor/editor.tsx +++ b/public/editor/editor.tsx @@ -1,22 +1,12 @@ import { useState, useEffect } from 'react' import { createRoot } from 'react-dom/client' import { Button } from '@public/components/ui/button' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle -} from '@public/components/ui/card' -import { Input } from '@public/components/ui/input' -import { Label } from '@public/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@public/components/ui/tabs' -import { Badge } from '@public/components/ui/badge' import { Dialog, DialogContent, @@ -25,380 +15,56 @@ import { 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(null) - const [loading, setLoading] = useState(true) - - // Sites state - const [sites, setSites] = useState([]) - const [sitesLoading, setSitesLoading] = useState(true) - const [isSyncing, setIsSyncing] = useState(false) - - // Domains state - const [wispDomain, setWispDomain] = useState(null) - const [customDomains, setCustomDomains] = useState([]) - const [domainsLoading, setDomainsLoading] = useState(true) - - // Site configuration state + // 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() + + // Site configuration modal state (shared across components) const [configuringSite, setConfiguringSite] = useState(null) const [selectedDomains, setSelectedDomains] = useState>(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('') - const [newSiteName, setNewSiteName] = useState('') - const [selectedFiles, setSelectedFiles] = useState(null) - const [isUploading, setIsUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState('') - const [skippedFiles, setSkippedFiles] = useState>([]) - const [uploadedCount, setUploadedCount] = useState(0) - - // Custom domain modal state - const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) - const [customDomain, setCustomDomain] = useState('') - const [isAddingDomain, setIsAddingDomain] = useState(false) - const [verificationStatus, setVerificationStatus] = useState<{ - [id: string]: 'idle' | 'verifying' | 'success' | 'error' - }>({}) - const [viewDomainDNS, setViewDomainDNS] = useState(null) - - // Wisp domain claim state - const [wispHandle, setWispHandle] = useState('') - const [isClaimingWisp, setIsClaimingWisp] = useState(false) - const [wispAvailability, setWispAvailability] = useState<{ - available: boolean | null - checking: boolean - }>({ available: null, checking: false }) - - // Fetch user info on mount + // 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) => { - 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) @@ -429,21 +95,9 @@ function Dashboard() { // 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 @@ -455,11 +109,7 @@ function Dashboard() { // 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) } } @@ -467,13 +117,7 @@ function Dashboard() { 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) } } @@ -499,86 +143,17 @@ function Dashboard() { } setIsDeletingSite(true) - try { - const response = await fetch(`/api/site/${configuringSite.rkey}`, { - method: 'DELETE' - }) - - const data = await response.json() - if (data.success) { - // Refresh sites list - await fetchSites() - // Refresh domains in case this site was mapped - await fetchDomains() - setConfiguringSite(null) - } else { - throw new Error(data.error || 'Failed to delete site') - } - } catch (err) { - console.error('Delete site error:', err) - alert( - `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` - ) - } finally { - setIsDeletingSite(false) - } - } - - const checkWispAvailability = async (handle: string) => { - const trimmedHandle = handle.trim().toLowerCase() - if (!trimmedHandle) { - setWispAvailability({ available: null, checking: false }) - return - } - - setWispAvailability({ available: null, checking: true }) - try { - const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) - const data = await response.json() - setWispAvailability({ available: data.available, checking: false }) - } catch (err) { - console.error('Check availability error:', err) - setWispAvailability({ available: false, checking: false }) + const success = await deleteSite(configuringSite.rkey) + if (success) { + // Refresh domains in case this site was mapped + await fetchDomains() + setConfiguringSite(null) } + setIsDeletingSite(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) { @@ -627,861 +202,49 @@ function Dashboard() { {/* Sites Tab */} - - - -
-
- Your Sites - - View and manage all your deployed sites - -
- -
-
- - {sitesLoading ? ( -
- -
- ) : sites.length === 0 ? ( -
-

No sites yet. Upload your first site!

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

- {site.display_name || site.rkey} -

- - active - -
- - {/* Display all mapped domains */} - {site.domains && site.domains.length > 0 ? ( -
- {site.domains.map((domainInfo, idx) => ( -
- - - {domainInfo.domain} - - - - {domainInfo.type} - - {domainInfo.type === 'custom' && ( - - {domainInfo.verified ? ( - <> - - verified - - ) : ( - <> - - pending - - )} - - )} -
- ))} -
- ) : ( - - {getSiteDomainName(site)} - - - )} -
- -
- )) - )} -
-
- -
-
- -
-

- Note about sites.wisp.place URLs -

-

- Complex sites hosted on sites.wisp.place may have broken assets if they use absolute paths (e.g., /folder/script.js) 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. -

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

- → Mapped to site: {wispDomain.rkey} -

- )} -
-

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

- - ) : ( -
-
-

- Claim your free wisp.place subdomain -

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

- - Checking availability... -

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

- - Available -

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

- - Not available -

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

- → Mapped to site: {domain.rkey} -

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

- File limits: 100MB per file, 300MB total -

-
- -
- - - -

- Upload Folder -

-

- Drag and drop or click to upload your - static site files -

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

- {selectedFiles.length} files selected -

- )} -
-
- - - - -

- Connect Git Repository -

-

- Link your GitHub, GitLab, or any Git - repository -

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

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

-
- -
-

Download CLI

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

Basic Usage

- -
- -
-

CI/CD with Tangled Spindle

-

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

- -
-
-

- Example 1: Simple Asset Publishing - Copy Files -

- -
- -
-

- Example 2: React/Vite Build & Deploy - Full Build -

- -
-
- -
-

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

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

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

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

- Domain: -

-

- {domain.domain} -

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

- Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification. -

-
-
- -
-

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

-
-
- ) - })()} - - )} - - - -
-
) } diff --git a/public/editor/hooks/useDomainData.ts b/public/editor/hooks/useDomainData.ts new file mode 100644 index 0000000..e084fe0 --- /dev/null +++ b/public/editor/hooks/useDomainData.ts @@ -0,0 +1,212 @@ +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(null) + const [customDomains, setCustomDomains] = useState([]) + 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 + } +} diff --git a/public/editor/hooks/useSiteData.ts b/public/editor/hooks/useSiteData.ts new file mode 100644 index 0000000..179fa15 --- /dev/null +++ b/public/editor/hooks/useSiteData.ts @@ -0,0 +1,112 @@ +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([]) + 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 + } +} diff --git a/public/editor/hooks/useUserInfo.ts b/public/editor/hooks/useUserInfo.ts new file mode 100644 index 0000000..1c3f51b --- /dev/null +++ b/public/editor/hooks/useUserInfo.ts @@ -0,0 +1,29 @@ +import { useState } from 'react' + +export interface UserInfo { + did: string + handle: string +} + +export function useUserInfo() { + const [userInfo, setUserInfo] = useState(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 + } +} diff --git a/public/editor/index.html b/public/editor/index.html index 28931c0..dea4bef 100644 --- a/public/editor/index.html +++ b/public/editor/index.html @@ -5,6 +5,24 @@ Elysia Static +
diff --git a/public/editor/tabs/CLITab.tsx b/public/editor/tabs/CLITab.tsx new file mode 100644 index 0000000..64ce462 --- /dev/null +++ b/public/editor/tabs/CLITab.tsx @@ -0,0 +1,258 @@ +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 ( +
+ + +
+ Wisp CLI Tool + v0.1.0 + Alpha +
+ + Deploy static sites directly from your terminal + +
+ +
+

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

+
+ +
+

Download CLI

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

Basic Usage

+ +
+ +
+

CI/CD with Tangled Spindle

+

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

+ +
+
+

+ Example 1: Simple Asset Publishing + Copy Files +

+ +
+ +
+

+ Example 2: React/Vite Build & Deploy + Full Build +

+ +
+
+ +
+

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

+
+
+ + +
+
+
+ ) +} diff --git a/public/editor/tabs/DomainsTab.tsx b/public/editor/tabs/DomainsTab.tsx new file mode 100644 index 0000000..8c4d2f5 --- /dev/null +++ b/public/editor/tabs/DomainsTab.tsx @@ -0,0 +1,499 @@ +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 + onDeleteCustomDomain: (id: string) => Promise + 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(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 ( + <> +
+ + + wisp.place Subdomain + + Your free subdomain on the wisp.place network + + + + {domainsLoading ? ( +
+ +
+ ) : wispDomain ? ( + <> +
+
+ + + {wispDomain.domain} + +
+ {wispDomain.rkey && ( +

+ → Mapped to site: {wispDomain.rkey} +

+ )} +
+

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

+ + ) : ( +
+
+

+ Claim your free wisp.place subdomain +

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

+ + Checking availability... +

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

+ + Available +

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

+ + Not available +

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

+ → Mapped to site: {domain.rkey} +

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

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

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

+ Domain: +

+

+ {domain.domain} +

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

+ Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification. +

+
+
+ +
+

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

+
+
+ ) + })()} + + )} + + + +
+
+ + ) +} diff --git a/public/editor/tabs/SitesTab.tsx b/public/editor/tabs/SitesTab.tsx new file mode 100644 index 0000000..6d5f8da --- /dev/null +++ b/public/editor/tabs/SitesTab.tsx @@ -0,0 +1,196 @@ +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 + 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 ( +
+ + +
+
+ Your Sites + + View and manage all your deployed sites + +
+ +
+
+ + {sitesLoading ? ( +
+ +
+ ) : sites.length === 0 ? ( +
+

No sites yet. Upload your first site!

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

+ {site.display_name || site.rkey} +

+ + active + +
+ + {/* Display all mapped domains */} + {site.domains && site.domains.length > 0 ? ( +
+ {site.domains.map((domainInfo, idx) => ( +
+ + + {domainInfo.domain} + + + + {domainInfo.type} + + {domainInfo.type === 'custom' && ( + + {domainInfo.verified ? ( + <> + + verified + + ) : ( + <> + + pending + + )} + + )} +
+ ))} +
+ ) : ( + + {getSiteDomainName(site)} + + + )} +
+ +
+ )) + )} +
+
+ +
+
+ +
+

+ Note about sites.wisp.place URLs +

+

+ Complex sites hosted on sites.wisp.place may have broken assets if they use absolute paths (e.g., /folder/script.js) 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. +

+
+
+
+
+ ) +} diff --git a/public/editor/tabs/UploadTab.tsx b/public/editor/tabs/UploadTab.tsx new file mode 100644 index 0000000..0cafde0 --- /dev/null +++ b/public/editor/tabs/UploadTab.tsx @@ -0,0 +1,494 @@ +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 +} + +// 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('') + const [newSiteName, setNewSiteName] = useState('') + const [selectedFiles, setSelectedFiles] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState('') + const [skippedFiles, setSkippedFiles] = useState>([]) + const [uploadedCount, setUploadedCount] = useState(0) + const [batchProgress, setBatchProgress] = useState(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) => { + 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 ( +
+ + + Upload Site + + Deploy a new site from a folder or Git repository + + + +
+
+ setSiteMode(value as 'existing' | 'new')} + disabled={isUploading} + > +
+ + +
+
+ + +
+
+
+ + {siteMode === 'existing' ? ( +
+ + {sitesLoading ? ( +
+ +
+ ) : sites.length === 0 ? ( +
+ No sites available. Create a new site instead. +
+ ) : ( + + )} +
+ ) : ( +
+ + setNewSiteName(e.target.value)} + disabled={isUploading} + /> +
+ )} + +

+ File limits: 100MB per file, 300MB total +

+
+ +
+ + + +

+ Upload Folder +

+

+ Drag and drop or click to upload your + static site files +

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

+ {selectedFiles.length} files selected +

+ )} +
+
+ + + + +

+ Connect Git Repository +

+

+ Link your GitHub, GitLab, or any Git + repository +

+ Coming soon! +
+
+
+ + {uploadProgress && ( +
+
+
+ + {uploadProgress} +
+ {batchProgress && ( +
+
+ + Uploaded: {batchProgress.uploaded}/{batchProgress.total} + + + Failed: {batchProgress.failed} + +
+
+
+
+
+ )} +
+ + {skippedFiles.length > 0 && ( +
+
+ +
+ + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped + + {uploadedCount > 0 && ( + + ({uploadedCount} uploaded successfully) + + )} +
+
+
+ {skippedFiles.slice(0, 5).map((file, idx) => ( +
+ {file.name} + - {file.reason} +
+ ))} + {skippedFiles.length > 5 && ( +
+ ...and {skippedFiles.length - 5} more +
+ )} +
+
+ )} +
+ )} + + + + +
+ ) +}