From 56a2017ce84880a258181126a030e7b997d296dd Mon Sep 17 00:00:00 2001
From: "nekomimi.pet"
Date: Sat, 8 Nov 2025 21:05:32 -0500
Subject: [PATCH] update frontend to allow multi domain routing to same site,
add backend routes for viewing sites
---
bun.lock | 3 +
package.json | 1 +
public/components/ui/checkbox.tsx | 30 +++
public/editor/editor.tsx | 302 ++++++++++++++++++++----------
src/lib/db.ts | 58 ++++++
src/routes/user.ts | 16 +-
6 files changed, 311 insertions(+), 99 deletions(-)
create mode 100644 public/components/ui/checkbox.tsx
diff --git a/bun.lock b/bun.lock
index 5c5dd02..87c990b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,6 +13,7 @@
"@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",
@@ -232,6 +233,8 @@
"@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=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
diff --git a/package.json b/package.json
index 123b81a..f8c07bb 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@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",
diff --git a/public/components/ui/checkbox.tsx b/public/components/ui/checkbox.tsx
new file mode 100644
index 0000000..5e4b674
--- /dev/null
+++ b/public/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+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) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/public/editor/editor.tsx b/public/editor/editor.tsx
index a11661e..da7530f 100644
--- a/public/editor/editor.tsx
+++ b/public/editor/editor.tsx
@@ -38,6 +38,7 @@ import {
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'
@@ -55,6 +56,17 @@ interface Site {
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
@@ -76,7 +88,7 @@ function Dashboard() {
const [loading, setLoading] = useState(true)
// Sites state
- const [sites, setSites] = useState([])
+ const [sites, setSites] = useState([])
const [sitesLoading, setSitesLoading] = useState(true)
const [isSyncing, setIsSyncing] = useState(false)
@@ -86,8 +98,8 @@ function Dashboard() {
const [domainsLoading, setDomainsLoading] = useState(true)
// Site configuration state
- const [configuringSite, setConfiguringSite] = useState(null)
- const [selectedDomain, setSelectedDomain] = useState('')
+ const [configuringSite, setConfiguringSite] = useState(null)
+ const [selectedDomains, setSelectedDomains] = useState>(new Set())
const [isSavingConfig, setIsSavingConfig] = useState(false)
const [isDeletingSite, setIsDeletingSite] = useState(false)
@@ -148,7 +160,29 @@ function Dashboard() {
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 {
@@ -189,34 +223,26 @@ function Dashboard() {
}
}
- const getSiteUrl = (site: Site) => {
- // Check if this site is mapped to the wisp.place domain
- if (wispDomain && wispDomain.rkey === site.rkey) {
- return `https://${wispDomain.domain}`
- }
-
- // Check if this site is mapped to any custom domain
- const customDomain = customDomains.find((d) => d.rkey === site.rkey)
- if (customDomain) {
- return `https://${customDomain.domain}`
+ 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
+ // 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 customDomain = customDomains.find((d) => d.rkey === site.rkey)
- if (customDomain) {
- return customDomain.domain
+ const getSiteDomainName = (site: SiteWithDomains) => {
+ // Return the first domain if available
+ if (site.domains && site.domains.length > 0) {
+ return site.domains[0].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) => {
@@ -373,20 +399,23 @@ function Dashboard() {
}
}
- 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()
+
+ 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 () => {
@@ -394,53 +423,63 @@ function Dashboard() {
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)
@@ -638,15 +677,60 @@ function Dashboard() {
active
-
- {getSiteDomainName(site)}
-
-
+
+ {/* 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)}
+
+
+ )}
-
+
+
Available Domains:
+
{wispDomain && (
-
-
+
+ {
+ const newSelected = new Set(selectedDomains)
+ if (checked) {
+ newSelected.add('wisp')
+ } else {
+ newSelected.delete('wisp')
+ }
+ setSelectedDomains(newSelected)
+ }}
+ />
@@ -1448,11 +1543,20 @@ steps:
.map((domain) => (
-
{
+ const newSelected = new Set(selectedDomains)
+ if (checked) {
+ newSelected.add(domain.id)
+ } else {
+ newSelected.delete(domain.id)
+ }
+ setSelectedDomains(newSelected)
+ }}
/>
+
+
+
+ Note: If no domains are selected, the site will be accessible at:{' '}
+
+ sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
+
+
+
)}
diff --git a/src/lib/db.ts b/src/lib/db.ts
index f86fa4c..887f212 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -578,3 +578,61 @@ export const deleteSite = async (did: string, rkey: string) => {
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),
+ };
+};
diff --git a/src/routes/user.ts b/src/routes/user.ts
index 6a3a1a1..c9217e2 100644
--- a/src/routes/user.ts
+++ b/src/routes/user.ts
@@ -2,7 +2,7 @@ import { Elysia } from 'elysia'
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'
@@ -98,3 +98,17 @@ export const userRoutes = (client: NodeOAuthClient) =>
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')
+ }
+ })
--
2.54.0