···
import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
+
import { Checkbox } from '@public/components/ui/checkbox'
import { CodeBlock } from '@public/components/ui/code-block'
import Layout from '@public/layouts'
···
59
+
interface DomainInfo {
60
+
type: 'wisp' | 'custom'
66
+
interface SiteWithDomains extends Site {
67
+
domains?: DomainInfo[]
···
const [loading, setLoading] = useState(true)
79
-
const [sites, setSites] = useState<Site[]>([])
91
+
const [sites, setSites] = useState<SiteWithDomains[]>([])
const [sitesLoading, setSitesLoading] = useState(true)
const [isSyncing, setIsSyncing] = useState(false)
···
const [domainsLoading, setDomainsLoading] = useState(true)
// Site configuration state
89
-
const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
90
-
const [selectedDomain, setSelectedDomain] = useState<string>('')
101
+
const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
102
+
const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
const [isSavingConfig, setIsSavingConfig] = useState(false)
const [isDeletingSite, setIsDeletingSite] = useState(false)
···
const response = await fetch('/api/user/sites')
const data = await response.json()
151
-
setSites(data.sites || [])
163
+
const sitesData: Site[] = data.sites || []
165
+
// Fetch domain info for each site
166
+
const sitesWithDomains = await Promise.all(
167
+
sitesData.map(async (site) => {
169
+
const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`)
170
+
const domainsData = await domainsResponse.json()
173
+
domains: domainsData.domains || []
176
+
console.error(`Failed to fetch domains for site ${site.rkey}:`, err)
185
+
setSites(sitesWithDomains)
console.error('Failed to fetch sites:', err)
···
192
-
const getSiteUrl = (site: Site) => {
193
-
// Check if this site is mapped to the wisp.place domain
194
-
if (wispDomain && wispDomain.rkey === site.rkey) {
195
-
return `https://${wispDomain.domain}`
226
+
const getSiteUrl = (site: SiteWithDomains) => {
227
+
// Use the first mapped domain if available
228
+
if (site.domains && site.domains.length > 0) {
229
+
return `https://${site.domains[0].domain}`
198
-
// Check if this site is mapped to any custom domain
199
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
200
-
if (customDomain) {
201
-
return `https://${customDomain.domain}`
204
-
// Default fallback URL
232
+
// Default fallback URL - use handle instead of DID
if (!userInfo) return '#'
206
-
return `https://sites.wisp.place/${site.did}/${site.rkey}`
234
+
return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
209
-
const getSiteDomainName = (site: Site) => {
210
-
if (wispDomain && wispDomain.rkey === site.rkey) {
211
-
return wispDomain.domain
237
+
const getSiteDomainName = (site: SiteWithDomains) => {
238
+
// Return the first domain if available
239
+
if (site.domains && site.domains.length > 0) {
240
+
return site.domains[0].domain
214
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
215
-
if (customDomain) {
216
-
return customDomain.domain
219
-
return `sites.wisp.place/${site.did}/${site.rkey}`
243
+
// Use handle instead of DID for display
244
+
if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
245
+
return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
···
376
-
const handleConfigureSite = (site: Site) => {
402
+
const handleConfigureSite = (site: SiteWithDomains) => {
379
-
// Determine current domain mapping
380
-
if (wispDomain && wispDomain.rkey === site.rkey) {
381
-
setSelectedDomain('wisp')
383
-
const customDomain = customDomains.find((d) => d.rkey === site.rkey)
384
-
if (customDomain) {
385
-
setSelectedDomain(customDomain.id)
387
-
setSelectedDomain('none')
405
+
// Build set of currently mapped domains
406
+
const mappedDomains = new Set<string>()
408
+
if (site.domains) {
409
+
site.domains.forEach(domainInfo => {
410
+
if (domainInfo.type === 'wisp') {
411
+
mappedDomains.add('wisp')
412
+
} else if (domainInfo.id) {
413
+
mappedDomains.add(domainInfo.id)
418
+
setSelectedDomains(mappedDomains)
const handleSaveSiteConfig = async () => {
···
397
-
if (selectedDomain === 'wisp') {
398
-
// Map to wisp.place domain
426
+
// Determine which domains should be mapped/unmapped
427
+
const shouldMapWisp = selectedDomains.has('wisp')
428
+
const isCurrentlyMappedToWisp = wispDomain && wispDomain.rkey === configuringSite.rkey
430
+
// Handle wisp domain mapping
431
+
if (shouldMapWisp && !isCurrentlyMappedToWisp) {
432
+
// Map to wisp domain
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()
405
-
if (!data.success) throw new Error('Failed to map site')
406
-
} else if (selectedDomain === 'none') {
407
-
// Unmap from all domains
408
-
// Unmap wisp domain if this site was mapped to it
409
-
if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
410
-
await fetch('/api/domain/wisp/map-site', {
439
+
if (!data.success) throw new Error('Failed to map wisp domain')
440
+
} else if (!shouldMapWisp && isCurrentlyMappedToWisp) {
441
+
// Unmap from wisp domain
442
+
await fetch('/api/domain/wisp/map-site', {
444
+
headers: { 'Content-Type': 'application/json' },
445
+
body: JSON.stringify({ siteRkey: null })
449
+
// Handle custom domain mappings
450
+
const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => id !== 'wisp')
451
+
const currentlyMappedCustomDomains = customDomains.filter(
452
+
d => d.rkey === configuringSite.rkey
455
+
// Unmap domains that are no longer selected
456
+
for (const domain of currentlyMappedCustomDomains) {
457
+
if (!selectedCustomDomainIds.includes(domain.id)) {
458
+
await fetch(`/api/domain/custom/${domain.id}/map-site`, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ siteRkey: null })
417
-
// Unmap from custom domains
418
-
const mappedCustom = customDomains.find(
419
-
(d) => d.rkey === configuringSite.rkey
421
-
if (mappedCustom) {
422
-
await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
466
+
// Map newly selected domains
467
+
for (const domainId of selectedCustomDomainIds) {
468
+
const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
469
+
if (!isAlreadyMapped) {
470
+
const response = await fetch(`/api/domain/custom/${domainId}/map-site`, {
headers: { 'Content-Type': 'application/json' },
425
-
body: JSON.stringify({ siteRkey: null })
473
+
body: JSON.stringify({ siteRkey: configuringSite.rkey })
475
+
const data = await response.json()
476
+
if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`)
429
-
// Map to a custom domain
430
-
const response = await fetch(
431
-
`/api/domain/custom/${selectedDomain}/map-site`,
434
-
headers: { 'Content-Type': 'application/json' },
435
-
body: JSON.stringify({ siteRkey: configuringSite.rkey })
438
-
const data = await response.json()
439
-
if (!data.success) throw new Error('Failed to map site')
442
-
// Refresh domains to get updated mappings
480
+
// Refresh both domains and sites to get updated mappings
console.error('Save config error:', err)
···
642
-
href={getSiteUrl(site)}
644
-
rel="noopener noreferrer"
645
-
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
647
-
{getSiteDomainName(site)}
648
-
<ExternalLink className="w-3 h-3" />
681
+
{/* Display all mapped domains */}
682
+
{site.domains && site.domains.length > 0 ? (
683
+
<div className="space-y-1">
684
+
{site.domains.map((domainInfo, idx) => (
685
+
<div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
687
+
href={`https://${domainInfo.domain}`}
689
+
rel="noopener noreferrer"
690
+
className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
692
+
<Globe className="w-3 h-3" />
693
+
{domainInfo.domain}
694
+
<ExternalLink className="w-3 h-3" />
697
+
variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
698
+
className="text-xs"
702
+
{domainInfo.type === 'custom' && (
704
+
variant={domainInfo.verified ? 'default' : 'secondary'}
705
+
className="text-xs"
707
+
{domainInfo.verified ? (
709
+
<CheckCircle2 className="w-3 h-3 mr-1" />
714
+
<AlertCircle className="w-3 h-3 mr-1" />
725
+
href={getSiteUrl(site)}
727
+
rel="noopener noreferrer"
728
+
className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
730
+
{getSiteDomainName(site)}
731
+
<ExternalLink className="w-3 h-3" />
···
<DialogContent className="sm:max-w-lg">
1408
-
<DialogTitle>Configure Site Domain</DialogTitle>
1492
+
<DialogTitle>Configure Site Domains</DialogTitle>
1410
-
Choose which domain this site should use
1494
+
Select which domains should be mapped to this site. You can select multiple domains.
···
1424
-
value={selectedDomain}
1425
-
onValueChange={setSelectedDomain}
1507
+
<div className="space-y-3">
1508
+
<p className="text-sm font-medium">Available Domains:</p>
1428
-
<div className="flex items-center space-x-2">
1429
-
<RadioGroupItem value="wisp" id="wisp" />
1511
+
<div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
1514
+
checked={selectedDomains.has('wisp')}
1515
+
onCheckedChange={(checked) => {
1516
+
const newSelected = new Set(selectedDomains)
1518
+
newSelected.add('wisp')
1520
+
newSelected.delete('wisp')
1522
+
setSelectedDomains(newSelected)
className="flex-1 cursor-pointer"
···
<Badge variant="secondary" className="text-xs ml-2">
···
1451
-
className="flex items-center space-x-2"
1546
+
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
1550
+
checked={selectedDomains.has(domain.id)}
1551
+
onCheckedChange={(checked) => {
1552
+
const newSelected = new Set(selectedDomains)
1554
+
newSelected.add(domain.id)
1556
+
newSelected.delete(domain.id)
1558
+
setSelectedDomains(newSelected)
···
1476
-
<div className="flex items-center space-x-2">
1477
-
<RadioGroupItem value="none" id="none" />
1478
-
<Label htmlFor="none" className="flex-1 cursor-pointer">
1479
-
<div className="flex flex-col">
1480
-
<span className="text-sm">Default URL</span>
1481
-
<span className="text-xs text-muted-foreground font-mono break-all">
1482
-
sites.wisp.place/{configuringSite.did}/
1483
-
{configuringSite.rkey}
1580
+
{customDomains.filter(d => d.verified).length === 0 && !wispDomain && (
1581
+
<p className="text-sm text-muted-foreground py-4 text-center">
1582
+
No domains available. Add a custom domain or claim your wisp.place subdomain.
1587
+
<div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
1588
+
<p className="text-xs text-muted-foreground">
1589
+
<strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
1590
+
<span className="font-mono">
1591
+
sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">