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

update frontend to allow multi domain routing to same site, add backend routes for viewing sites

Changed files
+311 -99
public
components
editor
src
lib
routes
+3
bun.lock
···
"@elysiajs/openapi": "^1.4.11",
"@elysiajs/opentelemetry": "^1.4.6",
"@elysiajs/static": "^1.4.2",
+
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
···
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
+
+
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
+1
package.json
···
"@elysiajs/openapi": "^1.4.11",
"@elysiajs/opentelemetry": "^1.4.6",
"@elysiajs/static": "^1.4.2",
+
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
+30
public/components/ui/checkbox.tsx
···
+
import * as React from "react"
+
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+
import { CheckIcon } from "lucide-react"
+
+
import { cn } from "@public/lib/utils"
+
+
function Checkbox({
+
className,
+
...props
+
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+
return (
+
<CheckboxPrimitive.Root
+
data-slot="checkbox"
+
className={cn(
+
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+
className
+
)}
+
{...props}
+
>
+
<CheckboxPrimitive.Indicator
+
data-slot="checkbox-indicator"
+
className="grid place-content-center text-current transition-none"
+
>
+
<CheckIcon className="size-3.5" />
+
</CheckboxPrimitive.Indicator>
+
</CheckboxPrimitive.Root>
+
)
+
}
+
+
export { Checkbox }
+204 -98
public/editor/editor.tsx
···
Settings
} 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'
···
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
···
const [loading, setLoading] = useState(true)
// Sites state
-
const [sites, setSites] = useState<Site[]>([])
+
const [sites, setSites] = useState<SiteWithDomains[]>([])
const [sitesLoading, setSitesLoading] = useState(true)
const [isSyncing, setIsSyncing] = useState(false)
···
const [domainsLoading, setDomainsLoading] = useState(true)
// Site configuration state
-
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
-
const [selectedDomain, setSelectedDomain] = useState<string>('')
+
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)
···
try {
const response = await fetch('/api/user/sites')
const data = await response.json()
-
setSites(data.sites || [])
+
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 {
···
}
}
-
const getSiteUrl = (site: Site) => {
-
// Check if this site is mapped to the wisp.place domain
-
if (wispDomain && wispDomain.rkey === site.rkey) {
-
return `https://${wispDomain.domain}`
+
const getSiteUrl = (site: SiteWithDomains) => {
+
// Use the first mapped domain if available
+
if (site.domains && site.domains.length > 0) {
+
return `https://${site.domains[0].domain}`
}
-
// Check if this site is mapped to any custom domain
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
-
if (customDomain) {
-
return `https://${customDomain.domain}`
-
}
-
-
// Default fallback URL
+
// Default fallback URL - use handle instead of DID
if (!userInfo) return '#'
-
return `https://sites.wisp.place/${site.did}/${site.rkey}`
+
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
}
-
const getSiteDomainName = (site: Site) => {
-
if (wispDomain && wispDomain.rkey === site.rkey) {
-
return wispDomain.domain
+
const getSiteDomainName = (site: SiteWithDomains) => {
+
// Return the first domain if available
+
if (site.domains && site.domains.length > 0) {
+
return site.domains[0].domain
}
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
-
if (customDomain) {
-
return customDomain.domain
-
}
-
-
return `sites.wisp.place/${site.did}/${site.rkey}`
+
// 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>) => {
···
}
}
-
const handleConfigureSite = (site: Site) => {
+
const handleConfigureSite = (site: SiteWithDomains) => {
setConfiguringSite(site)
-
// Determine current domain mapping
-
if (wispDomain && wispDomain.rkey === site.rkey) {
-
setSelectedDomain('wisp')
-
} else {
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
-
if (customDomain) {
-
setSelectedDomain(customDomain.id)
-
} else {
-
setSelectedDomain('none')
-
}
+
// Build set of currently mapped domains
+
const mappedDomains = new Set<string>()
+
+
if (site.domains) {
+
site.domains.forEach(domainInfo => {
+
if (domainInfo.type === 'wisp') {
+
mappedDomains.add('wisp')
+
} else if (domainInfo.id) {
+
mappedDomains.add(domainInfo.id)
+
}
+
})
}
+
+
setSelectedDomains(mappedDomains)
}
const handleSaveSiteConfig = async () => {
···
setIsSavingConfig(true)
try {
-
if (selectedDomain === 'wisp') {
-
// Map to wisp.place domain
+
// Determine which domains should be mapped/unmapped
+
const shouldMapWisp = selectedDomains.has('wisp')
+
const isCurrentlyMappedToWisp = wispDomain && wispDomain.rkey === configuringSite.rkey
+
+
// 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 site')
-
} else if (selectedDomain === 'none') {
-
// Unmap from all domains
-
// Unmap wisp domain if this site was mapped to it
-
if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
-
await fetch('/api/domain/wisp/map-site', {
+
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', {
+
method: 'POST',
+
headers: { 'Content-Type': 'application/json' },
+
body: JSON.stringify({ siteRkey: null })
+
})
+
}
+
+
// Handle custom domain mappings
+
const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => id !== 'wisp')
+
const currentlyMappedCustomDomains = customDomains.filter(
+
d => d.rkey === configuringSite.rkey
+
)
+
+
// 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 })
})
}
+
}
-
// Unmap from custom domains
-
const mappedCustom = customDomains.find(
-
(d) => d.rkey === configuringSite.rkey
-
)
-
if (mappedCustom) {
-
await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
+
// Map newly selected domains
+
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: null })
+
body: JSON.stringify({ siteRkey: configuringSite.rkey })
})
+
const data = await response.json()
+
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
}
-
} else {
-
// Map to a custom domain
-
const response = await fetch(
-
`/api/domain/custom/${selectedDomain}/map-site`,
-
{
-
method: 'POST',
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
-
}
-
)
-
const data = await response.json()
-
if (!data.success) throw new Error('Failed to map site')
}
-
// Refresh domains to get updated mappings
+
// Refresh both domains and sites to get updated mappings
await fetchDomains()
+
await fetchSites()
setConfiguringSite(null)
} catch (err) {
console.error('Save config error:', err)
···
active
</Badge>
</div>
-
<a
-
href={getSiteUrl(site)}
-
target="_blank"
-
rel="noopener noreferrer"
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
-
>
-
{getSiteDomainName(site)}
-
<ExternalLink className="w-3 h-3" />
-
</a>
+
+
{/* 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"
···
<DialogContent className="sm:max-w-lg">
<DialogHeader>
-
<DialogTitle>Configure Site Domain</DialogTitle>
+
<DialogTitle>Configure Site Domains</DialogTitle>
<DialogDescription>
-
Choose which domain this site should use
+
Select which domains should be mapped to this site. You can select multiple domains.
</DialogDescription>
</DialogHeader>
{configuringSite && (
···
</p>
</div>
-
<RadioGroup
-
value={selectedDomain}
-
onValueChange={setSelectedDomain}
-
>
+
<div className="space-y-3">
+
<p className="text-sm font-medium">Available Domains:</p>
+
{wispDomain && (
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="wisp" id="wisp" />
+
<div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
+
<Checkbox
+
id="wisp"
+
checked={selectedDomains.has('wisp')}
+
onCheckedChange={(checked) => {
+
const newSelected = new Set(selectedDomains)
+
if (checked) {
+
newSelected.add('wisp')
+
} else {
+
newSelected.delete('wisp')
+
}
+
setSelectedDomains(newSelected)
+
}}
+
/>
<Label
htmlFor="wisp"
className="flex-1 cursor-pointer"
···
{wispDomain.domain}
</span>
<Badge variant="secondary" className="text-xs ml-2">
-
Free
+
Wisp
</Badge>
</div>
</Label>
···
.map((domain) => (
<div
key={domain.id}
-
className="flex items-center space-x-2"
+
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
-
<RadioGroupItem
-
value={domain.id}
+
<Checkbox
id={domain.id}
+
checked={selectedDomains.has(domain.id)}
+
onCheckedChange={(checked) => {
+
const newSelected = new Set(selectedDomains)
+
if (checked) {
+
newSelected.add(domain.id)
+
} else {
+
newSelected.delete(domain.id)
+
}
+
setSelectedDomains(newSelected)
+
}}
/>
<Label
htmlFor={domain.id}
···
</div>
))}
-
<div className="flex items-center space-x-2">
-
<RadioGroupItem value="none" id="none" />
-
<Label htmlFor="none" className="flex-1 cursor-pointer">
-
<div className="flex flex-col">
-
<span className="text-sm">Default URL</span>
-
<span className="text-xs text-muted-foreground font-mono break-all">
-
sites.wisp.place/{configuringSite.did}/
-
{configuringSite.rkey}
-
</span>
-
</div>
-
</Label>
-
</div>
-
</RadioGroup>
+
{customDomains.filter(d => d.verified).length === 0 && !wispDomain && (
+
<p className="text-sm text-muted-foreground py-4 text-center">
+
No domains available. Add a custom domain or claim your wisp.place subdomain.
+
</p>
+
)}
+
</div>
+
+
<div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
+
<p className="text-xs text-muted-foreground">
+
<strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
+
<span className="font-mono">
+
sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
+
</span>
+
</p>
+
</div>
</div>
)}
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
+58
src/lib/db.ts
···
return { success: false, error: err };
}
};
+
+
// Get all domains (wisp + custom) mapped to a specific site
+
export const getDomainsBySite = async (did: string, rkey: string) => {
+
const domains: Array<{
+
type: 'wisp' | 'custom';
+
domain: string;
+
verified?: boolean;
+
id?: string;
+
}> = [];
+
+
// Check wisp domain
+
const wispDomain = await db`
+
SELECT domain, rkey FROM domains
+
WHERE did = ${did} AND rkey = ${rkey}
+
`;
+
if (wispDomain.length > 0) {
+
domains.push({
+
type: 'wisp',
+
domain: wispDomain[0].domain,
+
});
+
}
+
+
// Check custom domains
+
const customDomains = await db`
+
SELECT id, domain, verified FROM custom_domains
+
WHERE did = ${did} AND rkey = ${rkey}
+
ORDER BY created_at DESC
+
`;
+
for (const cd of customDomains) {
+
domains.push({
+
type: 'custom',
+
domain: cd.domain,
+
verified: cd.verified,
+
id: cd.id,
+
});
+
}
+
+
return domains;
+
};
+
+
// Get count of domains mapped to a specific site
+
export const getDomainCountBySite = async (did: string, rkey: string) => {
+
const wispCount = await db`
+
SELECT COUNT(*) as count FROM domains
+
WHERE did = ${did} AND rkey = ${rkey}
+
`;
+
+
const customCount = await db`
+
SELECT COUNT(*) as count FROM custom_domains
+
WHERE did = ${did} AND rkey = ${rkey}
+
`;
+
+
return {
+
wisp: Number(wispCount[0]?.count || 0),
+
custom: Number(customCount[0]?.count || 0),
+
total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0),
+
};
+
};
+15 -1
src/routes/user.ts
···
import { requireAuth } from '../lib/wisp-auth'
import { NodeOAuthClient } from '@atproto/oauth-client-node'
import { Agent } from '@atproto/api'
-
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db'
+
import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite } from '../lib/db'
import { syncSitesFromPDS } from '../lib/sync-sites'
import { logger } from '../lib/logger'
···
throw new Error('Failed to sync sites')
}
})
+
.get('/site/:rkey/domains', async ({ auth, params }) => {
+
try {
+
const { rkey } = params
+
const domains = await getDomainsBySite(auth.did, rkey)
+
+
return {
+
rkey,
+
domains
+
}
+
} catch (err) {
+
logger.error('[User] Site domains error', err)
+
throw new Error('Failed to get domains for site')
+
}
+
})