···
import { createRoot } from 'react-dom/client'
import { Button } from '@public/components/ui/button'
-
} from '@public/components/ui/card'
-
import { Input } from '@public/components/ui/input'
-
import { Label } from '@public/components/ui/label'
} from '@public/components/ui/tabs'
-
import { Badge } from '@public/components/ui/badge'
···
} from '@public/components/ui/dialog'
-
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'
-
display_name: string | null
-
type: 'wisp' | 'custom'
-
interface SiteWithDomains extends Site {
-
interface CustomDomain {
-
last_verified_at: number | null
-
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
-
const [loading, setLoading] = useState(true)
-
const [sites, setSites] = useState<SiteWithDomains[]>([])
-
const [sitesLoading, setSitesLoading] = useState(true)
-
const [isSyncing, setIsSyncing] = useState(false)
-
const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
-
const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
-
const [domainsLoading, setDomainsLoading] = useState(true)
-
// Site configuration state
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)
-
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
-
}>({ available: null, checking: false })
-
// Fetch user info on mount
-
// Auto-switch to 'new' mode if no sites exist
-
if (!sitesLoading && sites.length === 0 && siteMode === 'existing') {
-
}, [sites, sitesLoading, siteMode])
-
const fetchUserInfo = async () => {
-
const response = await fetch('/api/user/info')
-
const data = await response.json()
-
console.error('Failed to fetch user info:', err)
-
const fetchSites = async () => {
-
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) => {
-
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
-
const domainsData = await domainsResponse.json()
-
domains: domainsData.domains || []
-
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
-
setSites(sitesWithDomains)
-
console.error('Failed to fetch sites:', err)
-
const syncSites = async () => {
-
const response = await fetch('/api/user/sync', {
-
const data = await response.json()
-
console.log(`Synced ${data.synced} sites from PDS`)
-
console.error('Failed to sync sites:', err)
-
alert('Failed to sync sites from PDS')
-
const fetchDomains = async () => {
-
const response = await fetch('/api/user/domains')
-
const data = await response.json()
-
setWispDomain(data.wispDomain)
-
setCustomDomains(data.customDomains || [])
-
console.error('Failed to fetch domains:', err)
-
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
-
alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name')
-
setUploadProgress('Preparing files...')
-
const formData = new FormData()
-
formData.append('siteName', siteName)
-
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', {
-
const data = await response.json()
-
setUploadProgress('Upload complete!')
-
setSkippedFiles(data.skippedFiles || [])
-
setUploadedCount(data.uploadedCount || data.fileCount || 0)
-
setSelectedSiteRkey('')
-
// Reset form - give more time if there are skipped files
-
const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500
-
throw new Error(data.error || 'Upload failed')
-
console.error('Upload error:', err)
-
`Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
-
const handleAddCustomDomain = async () => {
-
alert('Please enter a domain')
-
setIsAddingDomain(true)
-
const response = await fetch('/api/domain/custom/add', {
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ domain: customDomain })
-
const data = await response.json()
-
setAddDomainModalOpen(false)
-
// Automatically show DNS configuration for the newly added domain
-
setViewDomainDNS(data.id)
-
throw new Error(data.error || 'Failed to add domain')
-
console.error('Add domain error:', err)
-
`Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
-
setIsAddingDomain(false)
-
const handleVerifyDomain = async (id: string) => {
-
setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
-
const response = await fetch('/api/domain/custom/verify', {
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ id })
-
const data = await response.json()
-
if (data.success && data.verified) {
-
setVerificationStatus({ ...verificationStatus, [id]: 'success' })
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
-
alert(`Verification failed: ${data.error}`)
-
console.error('Verify domain error:', err)
-
setVerificationStatus({ ...verificationStatus, [id]: 'error' })
-
`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?')) {
-
const response = await fetch(`/api/domain/custom/${id}`, {
-
const data = await response.json()
-
throw new Error('Failed to delete domain')
-
console.error('Delete domain error:', err)
-
`Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
const handleConfigureSite = (site: SiteWithDomains) => {
···
// Handle wisp domain mapping
if (shouldMapWisp && !isCurrentlyMappedToWisp) {
-
const response = await fetch('/api/domain/wisp/map-site', {
-
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')
} else if (!shouldMapWisp && isCurrentlyMappedToWisp) {
-
// Unmap from wisp domain
-
await fetch('/api/domain/wisp/map-site', {
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ siteRkey: 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`, {
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ siteRkey: null })
···
for (const domainId of selectedCustomDomainIds) {
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
-
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
-
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}`)
···
-
const response = await fetch(`/api/site/${configuringSite.rkey}`, {
-
const data = await response.json()
-
// Refresh domains in case this site was mapped
-
setConfiguringSite(null)
-
throw new Error(data.error || 'Failed to delete site')
-
console.error('Delete site error:', err)
-
`Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}`
-
setIsDeletingSite(false)
-
const checkWispAvailability = async (handle: string) => {
-
const trimmedHandle = handle.trim().toLowerCase()
-
setWispAvailability({ available: null, checking: false })
-
setWispAvailability({ available: null, checking: true })
-
const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`)
-
const data = await response.json()
-
setWispAvailability({ available: data.available, checking: false })
-
console.error('Check availability error:', err)
-
setWispAvailability({ available: false, checking: false })
-
const handleClaimWispDomain = async () => {
-
const trimmedHandle = wispHandle.trim().toLowerCase()
-
alert('Please enter a handle')
-
setIsClaimingWisp(true)
-
const response = await fetch('/api/domain/claim', {
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ handle: trimmedHandle })
-
const data = await response.json()
-
setWispAvailability({ available: null, checking: false })
-
throw new Error(data.error || 'Failed to claim domain')
-
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.')
-
alert(`Failed to claim domain: ${errorMessage}`)
-
setIsClaimingWisp(false)
···
-
<TabsContent value="sites" className="space-y-4 min-h-[400px]">
-
<div className="flex items-center justify-between">
-
<CardTitle>Your Sites</CardTitle>
-
View and manage all your deployed sites
-
disabled={isSyncing || sitesLoading}
-
className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
-
<CardContent className="space-y-4">
-
<div className="flex items-center justify-center py-8">
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
-
) : sites.length === 0 ? (
-
<div className="text-center py-8 text-muted-foreground">
-
<p>No sites yet. Upload your first site!</p>
-
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}
-
{/* 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">
-
href={`https://${domainInfo.domain}`}
-
rel="noopener noreferrer"
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
-
<Globe className="w-3 h-3" />
-
<ExternalLink className="w-3 h-3" />
-
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
-
{domainInfo.type === 'custom' && (
-
variant={domainInfo.verified ? 'default' : 'secondary'}
-
{domainInfo.verified ? (
-
<CheckCircle2 className="w-3 h-3 mr-1" />
-
<AlertCircle className="w-3 h-3 mr-1" />
-
href={getSiteUrl(site)}
-
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" />
-
onClick={() => handleConfigureSite(site)}
-
<Settings className="w-4 h-4 mr-2" />
-
<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 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.
-
<TabsContent value="domains" className="space-y-4 min-h-[400px]">
-
<CardTitle>wisp.place Subdomain</CardTitle>
-
Your free subdomain on the wisp.place network
-
<div className="flex items-center justify-center py-4">
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
-
<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">
-
<p className="text-xs text-muted-foreground ml-7">
-
→ Mapped to site: {wispDomain.rkey}
-
<p className="text-sm text-muted-foreground mt-3">
-
? 'This domain is mapped to a specific site'
-
: 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
-
<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
-
<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">
-
setWispHandle(e.target.value)
-
if (e.target.value.trim()) {
-
checkWispAvailability(e.target.value)
-
setWispAvailability({ available: null, checking: false })
-
disabled={isClaimingWisp}
-
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
-
{wispAvailability.checking && (
-
<p className="text-xs text-muted-foreground flex items-center gap-1">
-
<Loader2 className="w-3 h-3 animate-spin" />
-
Checking availability...
-
{!wispAvailability.checking && wispAvailability.available === true && (
-
<p className="text-xs text-green-600 flex items-center gap-1">
-
<CheckCircle2 className="w-3 h-3" />
-
{!wispAvailability.checking && wispAvailability.available === false && (
-
<p className="text-xs text-red-600 flex items-center gap-1">
-
<XCircle className="w-3 h-3" />
-
onClick={handleClaimWispDomain}
-
disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true}
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
-
<CardTitle>Custom Domains</CardTitle>
-
Bring your own domain with DNS verification
-
<CardContent className="space-y-4">
-
onClick={() => setAddDomainModalOpen(true)}
-
<div className="flex items-center justify-center py-4">
-
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
-
) : customDomains.length === 0 ? (
-
<div className="text-center py-4 text-muted-foreground text-sm">
-
No custom domains added yet
-
<div className="space-y-2">
-
{customDomains.map((domain) => (
-
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">
-
<CheckCircle2 className="w-4 h-4 text-green-500" />
-
<XCircle className="w-4 h-4 text-red-500" />
-
<span className="font-mono">
-
{domain.rkey && domain.rkey !== 'self' && (
-
<p className="text-xs text-muted-foreground ml-6">
-
→ Mapped to site: {domain.rkey}
-
<div className="flex items-center gap-2">
-
setViewDomainDNS(domain.id)
-
<Badge variant="secondary">
-
handleVerifyDomain(domain.id)
-
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
-
handleDeleteCustomDomain(
-
<Trash2 className="w-4 h-4" />
-
<TabsContent value="upload" className="space-y-4 min-h-[400px]">
-
<CardTitle>Upload Site</CardTitle>
-
Deploy a new site from a folder or Git repository
-
<CardContent className="space-y-6">
-
<div className="space-y-4">
-
<div className="p-4 bg-muted/50 rounded-lg">
-
onValueChange={(value) => setSiteMode(value as 'existing' | 'new')}
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="existing" id="existing" />
-
<Label htmlFor="existing" className="cursor-pointer">
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="new" id="new" />
-
<Label htmlFor="new" className="cursor-pointer">
-
{siteMode === 'existing' ? (
-
<div className="space-y-2">
-
<Label htmlFor="site-select">Select Site</Label>
-
<div className="flex items-center justify-center py-4">
-
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
-
) : 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.
-
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)}
-
<option value="">Select a site...</option>
-
<option key={site.rkey} value={site.rkey}>
-
{site.display_name || site.rkey}
-
<div className="space-y-2">
-
<Label htmlFor="new-site-name">New Site Name</Label>
-
placeholder="my-awesome-site"
-
onChange={(e) => setNewSiteName(e.target.value)}
-
<p className="text-xs text-muted-foreground">
-
File limits: 100MB per file, 300MB total
-
<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">
-
<p className="text-sm text-muted-foreground mb-4">
-
Drag and drop or click to upload your
-
onChange={handleFileSelect}
-
{...(({ webkitdirectory: '', directory: '' } as any))}
-
<label htmlFor="file-upload">
-
.getElementById('file-upload')
-
{selectedFiles && selectedFiles.length > 0 && (
-
<p className="text-sm text-muted-foreground mt-3">
-
{selectedFiles.length} files selected
-
<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">
-
<p className="text-sm text-muted-foreground mb-4">
-
Link your GitHub, GitLab, or any Git
-
<Badge variant="secondary">Coming soon!</Badge>
-
<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>
-
{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
-
{uploadedCount > 0 && (
-
<span className="text-sm ml-2">
-
({uploadedCount} uploaded successfully)
-
<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>
-
{skippedFiles.length > 5 && (
-
<div className="text-xs text-muted-foreground">
-
...and {skippedFiles.length - 5} more
-
(siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) ||
-
(siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0))
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
-
{siteMode === 'existing' ? (
-
selectedFiles && selectedFiles.length > 0
-
<TabsContent value="cli" className="space-y-4 min-h-[400px]">
-
<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>
-
Deploy static sites directly from your terminal
-
<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.
-
<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">
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64"
-
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" />
-
<div className="text-xs text-muted-foreground">
-
<span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span>
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux"
-
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" />
-
<div className="text-xs text-muted-foreground">
-
<span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span>
-
<div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border">
-
href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux"
-
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" />
-
<div className="text-xs text-muted-foreground">
-
<span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span>
-
<div className="space-y-3">
-
<h3 className="text-sm font-semibold">Basic Usage</h3>
-
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 \\
-
# Your site will be available at:
-
# https://sites.wisp.place/your-handle/my-site`}
-
<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{' '}
-
href="https://blog.tangled.org/ci"
-
rel="noopener noreferrer"
-
className="text-accent hover:underline"
-
<div className="space-y-4">
-
<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>
-
SITE_PATH: '.' # Copy entire repo
-
SITE_NAME: 'myWebbedSite'
-
WISP_HANDLE: 'your-handle.bsky.social'
-
- name: deploy assets to wisp
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
-
--password "$WISP_APP_PASSWORD"
-
#Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite
-
#Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite
-
<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>
-
github:NixOS/nixpkgs/nixpkgs-unstable:
-
SITE_NAME: 'my-react-site'
-
WISP_HANDLE: 'your-handle.bsky.social'
-
# 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
-
curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli
-
--password "$WISP_APP_PASSWORD"`}
-
<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.
-
<div className="space-y-3">
-
<h3 className="text-sm font-semibold">Learn More</h3>
-
<div className="grid gap-2">
-
href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli"
-
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" />
-
href="https://blog.tangled.org/ci"
-
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" />
-
{/* Add Custom Domain Modal */}
-
<Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
-
<DialogContent className="sm:max-w-lg">
-
<DialogTitle>Add Custom Domain</DialogTitle>
-
Enter your domain name. After adding, you'll see the DNS
-
<div className="space-y-4 py-4">
-
<div className="space-y-2">
-
<Label htmlFor="new-domain">Domain Name</Label>
-
placeholder="example.com"
-
onChange={(e) => setCustomDomain(e.target.value)}
-
<p className="text-xs text-muted-foreground">
-
After adding, click "View DNS" to see the records you
-
<DialogFooter className="flex-col sm:flex-row gap-2">
-
setAddDomainModalOpen(false)
-
className="w-full sm:w-auto"
-
disabled={isAddingDomain}
-
onClick={handleAddCustomDomain}
-
disabled={!customDomain || isAddingDomain}
-
className="w-full sm:w-auto"
-
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{/* Site Configuration Modal */}
···
-
{/* View DNS Records Modal */}
-
open={viewDomainDNS !== null}
-
onOpenChange={(open) => !open && setViewDomainDNS(null)}
-
<DialogContent className="sm:max-w-lg">
-
<DialogTitle>DNS Configuration</DialogTitle>
-
Add these DNS records to your domain provider
-
{viewDomainDNS && userInfo && (
-
const domain = customDomains.find(
-
(d) => d.id === viewDomainDNS
-
if (!domain) return null
-
<div className="space-y-4 py-4">
-
<div className="p-3 bg-muted/30 rounded-lg">
-
<p className="text-sm font-medium mb-1">
-
<p className="font-mono text-sm">
-
<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)
-
<div className="font-mono text-xs space-y-2">
-
<span className="text-muted-foreground">
-
<span className="select-all">
-
<span className="text-muted-foreground">
-
<span className="select-all break-all">
-
<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)
-
<div className="font-mono text-xs space-y-2">
-
<span className="text-muted-foreground">
-
<span className="select-all">
-
<span className="text-muted-foreground">
-
<span className="select-all">
-
{domain.id}.dns.wisp.place
-
<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.
-
<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
-
onClick={() => setViewDomainDNS(null)}
-
className="w-full sm:w-auto"