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
··· 13 13 "@elysiajs/openapi": "^1.4.11", 14 14 "@elysiajs/opentelemetry": "^1.4.6", 15 15 "@elysiajs/static": "^1.4.2", 16 + "@radix-ui/react-checkbox": "^1.3.3", 16 17 "@radix-ui/react-dialog": "^1.1.15", 17 18 "@radix-ui/react-label": "^2.1.7", 18 19 "@radix-ui/react-radio-group": "^1.3.8", ··· 231 232 "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 232 233 233 234 "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], 235 + 236 + "@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=="], 234 237 235 238 "@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=="], 236 239
+1
package.json
··· 17 17 "@elysiajs/openapi": "^1.4.11", 18 18 "@elysiajs/opentelemetry": "^1.4.6", 19 19 "@elysiajs/static": "^1.4.2", 20 + "@radix-ui/react-checkbox": "^1.3.3", 20 21 "@radix-ui/react-dialog": "^1.1.15", 21 22 "@radix-ui/react-label": "^2.1.7", 22 23 "@radix-ui/react-radio-group": "^1.3.8",
+30
public/components/ui/checkbox.tsx
··· 1 + import * as React from "react" 2 + import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 + import { CheckIcon } from "lucide-react" 4 + 5 + import { cn } from "@public/lib/utils" 6 + 7 + function Checkbox({ 8 + className, 9 + ...props 10 + }: React.ComponentProps<typeof CheckboxPrimitive.Root>) { 11 + return ( 12 + <CheckboxPrimitive.Root 13 + data-slot="checkbox" 14 + className={cn( 15 + "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", 16 + className 17 + )} 18 + {...props} 19 + > 20 + <CheckboxPrimitive.Indicator 21 + data-slot="checkbox-indicator" 22 + className="grid place-content-center text-current transition-none" 23 + > 24 + <CheckIcon className="size-3.5" /> 25 + </CheckboxPrimitive.Indicator> 26 + </CheckboxPrimitive.Root> 27 + ) 28 + } 29 + 30 + export { Checkbox }
+204 -98
public/editor/editor.tsx
··· 38 38 Settings 39 39 } from 'lucide-react' 40 40 import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 41 + import { Checkbox } from '@public/components/ui/checkbox' 41 42 import { CodeBlock } from '@public/components/ui/code-block' 42 43 43 44 import Layout from '@public/layouts' ··· 55 56 updated_at: number 56 57 } 57 58 59 + interface DomainInfo { 60 + type: 'wisp' | 'custom' 61 + domain: string 62 + verified?: boolean 63 + id?: string 64 + } 65 + 66 + interface SiteWithDomains extends Site { 67 + domains?: DomainInfo[] 68 + } 69 + 58 70 interface CustomDomain { 59 71 id: string 60 72 domain: string ··· 76 88 const [loading, setLoading] = useState(true) 77 89 78 90 // Sites state 79 - const [sites, setSites] = useState<Site[]>([]) 91 + const [sites, setSites] = useState<SiteWithDomains[]>([]) 80 92 const [sitesLoading, setSitesLoading] = useState(true) 81 93 const [isSyncing, setIsSyncing] = useState(false) 82 94 ··· 86 98 const [domainsLoading, setDomainsLoading] = useState(true) 87 99 88 100 // 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()) 91 103 const [isSavingConfig, setIsSavingConfig] = useState(false) 92 104 const [isDeletingSite, setIsDeletingSite] = useState(false) 93 105 ··· 148 160 try { 149 161 const response = await fetch('/api/user/sites') 150 162 const data = await response.json() 151 - setSites(data.sites || []) 163 + const sitesData: Site[] = data.sites || [] 164 + 165 + // Fetch domain info for each site 166 + const sitesWithDomains = await Promise.all( 167 + sitesData.map(async (site) => { 168 + try { 169 + const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`) 170 + const domainsData = await domainsResponse.json() 171 + return { 172 + ...site, 173 + domains: domainsData.domains || [] 174 + } 175 + } catch (err) { 176 + console.error(`Failed to fetch domains for site ${site.rkey}:`, err) 177 + return { 178 + ...site, 179 + domains: [] 180 + } 181 + } 182 + }) 183 + ) 184 + 185 + setSites(sitesWithDomains) 152 186 } catch (err) { 153 187 console.error('Failed to fetch sites:', err) 154 188 } finally { ··· 189 223 } 190 224 } 191 225 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}` 196 230 } 197 231 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}` 202 - } 203 - 204 - // Default fallback URL 232 + // Default fallback URL - use handle instead of DID 205 233 if (!userInfo) return '#' 206 - return `https://sites.wisp.place/${site.did}/${site.rkey}` 234 + return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}` 207 235 } 208 236 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 212 241 } 213 242 214 - const customDomain = customDomains.find((d) => d.rkey === site.rkey) 215 - if (customDomain) { 216 - return customDomain.domain 217 - } 218 - 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}` 220 246 } 221 247 222 248 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { ··· 373 399 } 374 400 } 375 401 376 - const handleConfigureSite = (site: Site) => { 402 + const handleConfigureSite = (site: SiteWithDomains) => { 377 403 setConfiguringSite(site) 378 404 379 - // Determine current domain mapping 380 - if (wispDomain && wispDomain.rkey === site.rkey) { 381 - setSelectedDomain('wisp') 382 - } else { 383 - const customDomain = customDomains.find((d) => d.rkey === site.rkey) 384 - if (customDomain) { 385 - setSelectedDomain(customDomain.id) 386 - } else { 387 - setSelectedDomain('none') 388 - } 405 + // Build set of currently mapped domains 406 + const mappedDomains = new Set<string>() 407 + 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) 414 + } 415 + }) 389 416 } 417 + 418 + setSelectedDomains(mappedDomains) 390 419 } 391 420 392 421 const handleSaveSiteConfig = async () => { ··· 394 423 395 424 setIsSavingConfig(true) 396 425 try { 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 429 + 430 + // Handle wisp domain mapping 431 + if (shouldMapWisp && !isCurrentlyMappedToWisp) { 432 + // Map to wisp domain 399 433 const response = await fetch('/api/domain/wisp/map-site', { 400 434 method: 'POST', 401 435 headers: { 'Content-Type': 'application/json' }, 402 436 body: JSON.stringify({ siteRkey: configuringSite.rkey }) 403 437 }) 404 438 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', { 443 + method: 'POST', 444 + headers: { 'Content-Type': 'application/json' }, 445 + body: JSON.stringify({ siteRkey: null }) 446 + }) 447 + } 448 + 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 453 + ) 454 + 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`, { 411 459 method: 'POST', 412 460 headers: { 'Content-Type': 'application/json' }, 413 461 body: JSON.stringify({ siteRkey: null }) 414 462 }) 415 463 } 464 + } 416 465 417 - // Unmap from custom domains 418 - const mappedCustom = customDomains.find( 419 - (d) => d.rkey === configuringSite.rkey 420 - ) 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`, { 423 471 method: 'POST', 424 472 headers: { 'Content-Type': 'application/json' }, 425 - body: JSON.stringify({ siteRkey: null }) 473 + body: JSON.stringify({ siteRkey: configuringSite.rkey }) 426 474 }) 475 + const data = await response.json() 476 + if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`) 427 477 } 428 - } else { 429 - // Map to a custom domain 430 - const response = await fetch( 431 - `/api/domain/custom/${selectedDomain}/map-site`, 432 - { 433 - method: 'POST', 434 - headers: { 'Content-Type': 'application/json' }, 435 - body: JSON.stringify({ siteRkey: configuringSite.rkey }) 436 - } 437 - ) 438 - const data = await response.json() 439 - if (!data.success) throw new Error('Failed to map site') 440 478 } 441 479 442 - // Refresh domains to get updated mappings 480 + // Refresh both domains and sites to get updated mappings 443 481 await fetchDomains() 482 + await fetchSites() 444 483 setConfiguringSite(null) 445 484 } catch (err) { 446 485 console.error('Save config error:', err) ··· 638 677 active 639 678 </Badge> 640 679 </div> 641 - <a 642 - href={getSiteUrl(site)} 643 - target="_blank" 644 - rel="noopener noreferrer" 645 - className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 646 - > 647 - {getSiteDomainName(site)} 648 - <ExternalLink className="w-3 h-3" /> 649 - </a> 680 + 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"> 686 + <a 687 + href={`https://${domainInfo.domain}`} 688 + target="_blank" 689 + rel="noopener noreferrer" 690 + className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 691 + > 692 + <Globe className="w-3 h-3" /> 693 + {domainInfo.domain} 694 + <ExternalLink className="w-3 h-3" /> 695 + </a> 696 + <Badge 697 + variant={domainInfo.type === 'wisp' ? 'default' : 'outline'} 698 + className="text-xs" 699 + > 700 + {domainInfo.type} 701 + </Badge> 702 + {domainInfo.type === 'custom' && ( 703 + <Badge 704 + variant={domainInfo.verified ? 'default' : 'secondary'} 705 + className="text-xs" 706 + > 707 + {domainInfo.verified ? ( 708 + <> 709 + <CheckCircle2 className="w-3 h-3 mr-1" /> 710 + verified 711 + </> 712 + ) : ( 713 + <> 714 + <AlertCircle className="w-3 h-3 mr-1" /> 715 + pending 716 + </> 717 + )} 718 + </Badge> 719 + )} 720 + </div> 721 + ))} 722 + </div> 723 + ) : ( 724 + <a 725 + href={getSiteUrl(site)} 726 + target="_blank" 727 + rel="noopener noreferrer" 728 + className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1" 729 + > 730 + {getSiteDomainName(site)} 731 + <ExternalLink className="w-3 h-3" /> 732 + </a> 733 + )} 650 734 </div> 651 735 <Button 652 736 variant="outline" ··· 1405 1489 > 1406 1490 <DialogContent className="sm:max-w-lg"> 1407 1491 <DialogHeader> 1408 - <DialogTitle>Configure Site Domain</DialogTitle> 1492 + <DialogTitle>Configure Site Domains</DialogTitle> 1409 1493 <DialogDescription> 1410 - Choose which domain this site should use 1494 + Select which domains should be mapped to this site. You can select multiple domains. 1411 1495 </DialogDescription> 1412 1496 </DialogHeader> 1413 1497 {configuringSite && ( ··· 1420 1504 </p> 1421 1505 </div> 1422 1506 1423 - <RadioGroup 1424 - value={selectedDomain} 1425 - onValueChange={setSelectedDomain} 1426 - > 1507 + <div className="space-y-3"> 1508 + <p className="text-sm font-medium">Available Domains:</p> 1509 + 1427 1510 {wispDomain && ( 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"> 1512 + <Checkbox 1513 + id="wisp" 1514 + checked={selectedDomains.has('wisp')} 1515 + onCheckedChange={(checked) => { 1516 + const newSelected = new Set(selectedDomains) 1517 + if (checked) { 1518 + newSelected.add('wisp') 1519 + } else { 1520 + newSelected.delete('wisp') 1521 + } 1522 + setSelectedDomains(newSelected) 1523 + }} 1524 + /> 1430 1525 <Label 1431 1526 htmlFor="wisp" 1432 1527 className="flex-1 cursor-pointer" ··· 1436 1531 {wispDomain.domain} 1437 1532 </span> 1438 1533 <Badge variant="secondary" className="text-xs ml-2"> 1439 - Free 1534 + Wisp 1440 1535 </Badge> 1441 1536 </div> 1442 1537 </Label> ··· 1448 1543 .map((domain) => ( 1449 1544 <div 1450 1545 key={domain.id} 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" 1452 1547 > 1453 - <RadioGroupItem 1454 - value={domain.id} 1548 + <Checkbox 1455 1549 id={domain.id} 1550 + checked={selectedDomains.has(domain.id)} 1551 + onCheckedChange={(checked) => { 1552 + const newSelected = new Set(selectedDomains) 1553 + if (checked) { 1554 + newSelected.add(domain.id) 1555 + } else { 1556 + newSelected.delete(domain.id) 1557 + } 1558 + setSelectedDomains(newSelected) 1559 + }} 1456 1560 /> 1457 1561 <Label 1458 1562 htmlFor={domain.id} ··· 1473 1577 </div> 1474 1578 ))} 1475 1579 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} 1484 - </span> 1485 - </div> 1486 - </Label> 1487 - </div> 1488 - </RadioGroup> 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. 1583 + </p> 1584 + )} 1585 + </div> 1586 + 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} 1592 + </span> 1593 + </p> 1594 + </div> 1489 1595 </div> 1490 1596 )} 1491 1597 <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
+58
src/lib/db.ts
··· 578 578 return { success: false, error: err }; 579 579 } 580 580 }; 581 + 582 + // Get all domains (wisp + custom) mapped to a specific site 583 + export const getDomainsBySite = async (did: string, rkey: string) => { 584 + const domains: Array<{ 585 + type: 'wisp' | 'custom'; 586 + domain: string; 587 + verified?: boolean; 588 + id?: string; 589 + }> = []; 590 + 591 + // Check wisp domain 592 + const wispDomain = await db` 593 + SELECT domain, rkey FROM domains 594 + WHERE did = ${did} AND rkey = ${rkey} 595 + `; 596 + if (wispDomain.length > 0) { 597 + domains.push({ 598 + type: 'wisp', 599 + domain: wispDomain[0].domain, 600 + }); 601 + } 602 + 603 + // Check custom domains 604 + const customDomains = await db` 605 + SELECT id, domain, verified FROM custom_domains 606 + WHERE did = ${did} AND rkey = ${rkey} 607 + ORDER BY created_at DESC 608 + `; 609 + for (const cd of customDomains) { 610 + domains.push({ 611 + type: 'custom', 612 + domain: cd.domain, 613 + verified: cd.verified, 614 + id: cd.id, 615 + }); 616 + } 617 + 618 + return domains; 619 + }; 620 + 621 + // Get count of domains mapped to a specific site 622 + export const getDomainCountBySite = async (did: string, rkey: string) => { 623 + const wispCount = await db` 624 + SELECT COUNT(*) as count FROM domains 625 + WHERE did = ${did} AND rkey = ${rkey} 626 + `; 627 + 628 + const customCount = await db` 629 + SELECT COUNT(*) as count FROM custom_domains 630 + WHERE did = ${did} AND rkey = ${rkey} 631 + `; 632 + 633 + return { 634 + wisp: Number(wispCount[0]?.count || 0), 635 + custom: Number(customCount[0]?.count || 0), 636 + total: Number(wispCount[0]?.count || 0) + Number(customCount[0]?.count || 0), 637 + }; 638 + };
+15 -1
src/routes/user.ts
··· 2 2 import { requireAuth } from '../lib/wisp-auth' 3 3 import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 4 import { Agent } from '@atproto/api' 5 - import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo } from '../lib/db' 5 + import { getSitesByDid, getDomainByDid, getCustomDomainsByDid, getWispDomainInfo, getDomainsBySite } from '../lib/db' 6 6 import { syncSitesFromPDS } from '../lib/sync-sites' 7 7 import { logger } from '../lib/logger' 8 8 ··· 98 98 throw new Error('Failed to sync sites') 99 99 } 100 100 }) 101 + .get('/site/:rkey/domains', async ({ auth, params }) => { 102 + try { 103 + const { rkey } = params 104 + const domains = await getDomainsBySite(auth.did, rkey) 105 + 106 + return { 107 + rkey, 108 + domains 109 + } 110 + } catch (err) { 111 + logger.error('[User] Site domains error', err) 112 + throw new Error('Failed to get domains for site') 113 + } 114 + })