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

Configure Feed

Select the types of activity you want to include in your feed.

new script for screenshots, corner mascot

+349 -10
+1
.gitignore
··· 1 1 .research/ 2 2 binaries/ 3 3 cache/ 4 + site-images/ 4 5 # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 5 6 .env 6 7 # dependencies
apps/main-app/public/corner.png

This is a binary file and will not be displayed.

+26 -6
apps/main-app/public/editor/editor.tsx
··· 30 30 import { WebhooksTab } from './tabs/WebhooksTab' 31 31 32 32 function Dashboard() { 33 + const dashboardInset = '2.8rem' 34 + const dashboardMaxWidth = '72rem' 35 + const mascotStyle = { 36 + left: `calc((100vw - min(${dashboardMaxWidth}, 100vw)) / 2 + ${dashboardInset})`, 37 + width: 'clamp(14rem, 19vw, 17rem)', 38 + } as const 39 + 33 40 // Use custom hooks 34 41 const { userInfo, loading, isAuthenticated, fetchUserInfo } = useUserInfo() 35 42 const { sites, sitesLoading, fetchSites, deleteSite } = useSiteData() ··· 394 401 } 395 402 396 403 return ( 397 - <div className="w-full h-screen bg-background flex flex-col font-mono overflow-hidden"> 404 + <div className="relative isolate w-full h-screen bg-background flex flex-col font-mono overflow-hidden"> 398 405 {/* Header */} 399 - <header className="w-full border-b border-border/40 bg-background flex-shrink-0"> 406 + <header className="relative z-10 w-full border-b border-border/40 bg-background flex-shrink-0"> 400 407 <div className="max-w-6xl w-full mx-auto px-6 py-6 flex items-start justify-between"> 401 408 <div className="space-y-2"> 402 409 <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1> ··· 419 426 </header> 420 427 421 428 {/* Main content area - fills remaining space */} 422 - <div className="flex-1 overflow-hidden flex flex-col"> 423 - <div className="container mx-auto px-6 py-6 max-w-6xl w-full flex flex-col h-full"> 429 + <div className="relative z-10 flex-1 overflow-hidden flex flex-col"> 430 + <div className="container relative mx-auto px-6 py-6 max-w-6xl w-full flex flex-col h-full overflow-visible"> 424 431 {/* Keyboard shortcuts hint */} 425 432 <div className="mb-4 flex items-center gap-4 text-xs text-muted-foreground flex-shrink-0"> 426 433 <div className="flex items-center gap-2"> ··· 436 443 </div> 437 444 </div> 438 445 439 - <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 overflow-hidden"> 446 + <Tabs 447 + value={activeTab} 448 + onValueChange={setActiveTab} 449 + className="relative z-10 flex flex-col flex-1 overflow-hidden" 450 + > 440 451 <TabsList className="grid w-full grid-cols-5 bg-card border-b border-border/50 rounded-none h-auto p-0 flex-shrink-0"> 441 452 <TabsTrigger 442 453 value="sites" ··· 540 551 </div> 541 552 </div> 542 553 554 + <div className="pointer-events-none absolute bottom-0 z-20" style={mascotStyle}> 555 + <img 556 + src="/corner.png" 557 + alt="" 558 + aria-hidden="true" 559 + className="w-full max-w-none -translate-x-[95%] translate-y-[14%] select-none" 560 + /> 561 + </div> 562 + 543 563 {/* Footer - always visible */} 544 - <footer className="border-t border-border/30 font-mono flex-shrink-0 bg-background"> 564 + <footer className="relative z-10 border-t border-border/30 font-mono flex-shrink-0 bg-background"> 545 565 <div className="container mx-auto px-6 py-4 max-w-6xl"> 546 566 <div className="flex items-center justify-between text-xs text-muted-foreground"> 547 567 <div className="flex items-center gap-6">
+119 -2
apps/main-app/public/landingpage.html
··· 21 21 22 22 <!-- Theme --> 23 23 <meta name="theme-color" content="#000000" /> 24 + <script> 25 + document.documentElement.classList.add('js'); 26 + </script> 24 27 25 28 <link rel="icon" type="image/x-icon" href="./favicon.ico"> 26 29 <link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"> ··· 228 231 margin-bottom: 0.25rem; 229 232 } 230 233 234 + .js .terminal[data-animate-on-view] .terminal-body > .terminal-line, 235 + .js .terminal[data-animate-on-view] .terminal-body > .terminal-spacer { 236 + opacity: 0; 237 + transform: translateY(10px); 238 + transition: opacity 240ms ease, transform 240ms ease; 239 + will-change: opacity, transform; 240 + } 241 + 242 + .js .terminal[data-animate-on-view] .terminal-body > .terminal-line.is-visible, 243 + .js .terminal[data-animate-on-view] .terminal-body > .terminal-spacer.is-visible, 244 + .js .terminal[data-animate-on-view].is-complete .terminal-body > .terminal-line, 245 + .js .terminal[data-animate-on-view].is-complete .terminal-body > .terminal-spacer { 246 + opacity: 1; 247 + transform: translateY(0); 248 + } 249 + 231 250 .t-prompt { color: #7c7cff; } 232 251 .t-cmd { color: #c9d1d9; } 233 252 .t-muted { color: #666; } ··· 265 284 .t-success { color: #1a7f37; } 266 285 .t-cyan { color: #0969da; } 267 286 .t-output { color: #656d76; } 287 + } 288 + 289 + @media (prefers-reduced-motion: reduce) { 290 + .js .terminal[data-animate-on-view] .terminal-body > .terminal-line, 291 + .js .terminal[data-animate-on-view] .terminal-body > .terminal-spacer { 292 + transition: none; 293 + transform: none; 294 + } 268 295 } 269 296 270 297 /* Features */ ··· 517 544 518 545 <section class="demo"> 519 546 <div class="demo-inner"> 520 - <div class="terminal"> 547 + <div class="terminal" data-animate-on-view> 521 548 <div class="terminal-header"> 522 549 <div class="terminal-dots"> 523 550 <span class="terminal-dot red"></span> ··· 604 631 </div> 605 632 </div> 606 633 <div> 607 - <div class="terminal"> 634 + <div class="terminal" data-animate-on-view data-visible-items="5"> 608 635 <div class="terminal-header"> 609 636 <div class="terminal-dots"> 610 637 <span class="terminal-dot red"></span> ··· 679 706 </nav> 680 707 </div> 681 708 </footer> 709 + <script> 710 + document.addEventListener('DOMContentLoaded', () => { 711 + const terminals = Array.from(document.querySelectorAll('.terminal[data-animate-on-view]')); 712 + 713 + if (!terminals.length) { 714 + return; 715 + } 716 + 717 + const revealAll = (terminal) => { 718 + terminal.classList.add('is-complete'); 719 + 720 + for (const item of terminal.querySelectorAll('.terminal-body > .terminal-line, .terminal-body > .terminal-spacer')) { 721 + item.classList.add('is-visible'); 722 + } 723 + }; 724 + 725 + const getVisibleItems = (terminal) => { 726 + const items = Array.from(terminal.querySelectorAll('.terminal-body > .terminal-line, .terminal-body > .terminal-spacer')); 727 + const visibleCount = Number.parseInt(terminal.dataset.visibleItems || '0', 10); 728 + 729 + if (Number.isNaN(visibleCount) || visibleCount <= 0) { 730 + return { items, hiddenItems: items }; 731 + } 732 + 733 + items.slice(0, visibleCount).forEach((item) => { 734 + item.classList.add('is-visible'); 735 + }); 736 + 737 + return { 738 + items, 739 + hiddenItems: items.slice(visibleCount), 740 + }; 741 + }; 742 + 743 + terminals.forEach((terminal) => { 744 + getVisibleItems(terminal); 745 + }); 746 + 747 + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 748 + terminals.forEach(revealAll); 749 + return; 750 + } 751 + 752 + const animateTerminal = (terminal) => { 753 + const { hiddenItems } = getVisibleItems(terminal); 754 + const lineDelayMs = 180; 755 + 756 + if (!hiddenItems.length) { 757 + terminal.classList.add('is-complete'); 758 + return; 759 + } 760 + 761 + hiddenItems.forEach((item, index) => { 762 + window.setTimeout(() => { 763 + item.classList.add('is-visible'); 764 + 765 + if (index === hiddenItems.length - 1) { 766 + terminal.classList.add('is-complete'); 767 + } 768 + }, index * lineDelayMs); 769 + }); 770 + }; 771 + 772 + const observer = new IntersectionObserver((entries) => { 773 + for (const entry of entries) { 774 + if (!entry.isIntersecting) { 775 + continue; 776 + } 777 + 778 + const terminal = entry.target; 779 + 780 + if (terminal.dataset.animated === 'true') { 781 + observer.unobserve(terminal); 782 + continue; 783 + } 784 + 785 + terminal.dataset.animated = 'true'; 786 + animateTerminal(terminal); 787 + observer.unobserve(terminal); 788 + } 789 + }, { 790 + threshold: 0.35, 791 + rootMargin: '0px 0px -10% 0px', 792 + }); 793 + 794 + terminals.forEach((terminal) => { 795 + observer.observe(terminal); 796 + }); 797 + }); 798 + </script> 682 799 </body> 683 800 </html>
+200
apps/main-app/scripts/poll-site-images.ts
··· 1 + #!/usr/bin/env bun 2 + 3 + import { mkdir } from 'node:fs/promises' 4 + import { join } from 'node:path' 5 + import { chromium, type Page } from 'playwright' 6 + import { db } from '../src/lib/db' 7 + 8 + const SITE_IMAGES_DIR = join(process.cwd(), 'site-images') 9 + const VIEWPORT_WIDTH = 1920 10 + const VIEWPORT_HEIGHT = 1080 11 + const TIMEOUT = 10_000 12 + const MAX_RETRIES = 1 13 + const CONCURRENCY = 10 14 + 15 + interface DomainBackedSite { 16 + did: string 17 + rkey: string 18 + url: string 19 + domain: string 20 + domainType: 'custom' | 'wisp' 21 + } 22 + 23 + interface ScreenshotResult { 24 + success: boolean 25 + error?: string 26 + } 27 + 28 + interface DomainBackedSiteRow { 29 + did: string 30 + rkey: string 31 + domain: string 32 + domain_type: DomainBackedSite['domainType'] 33 + } 34 + 35 + async function getDomainBackedSites(): Promise<DomainBackedSite[]> { 36 + const rows = await db` 37 + SELECT 38 + s.did, 39 + s.rkey, 40 + COALESCE(cd.domain, d.domain) AS domain, 41 + CASE 42 + WHEN cd.domain IS NOT NULL THEN 'custom' 43 + ELSE 'wisp' 44 + END AS domain_type 45 + FROM sites s 46 + LEFT JOIN LATERAL ( 47 + SELECT domain 48 + FROM custom_domains 49 + WHERE did = s.did 50 + AND rkey = s.rkey 51 + AND verified = true 52 + ORDER BY created_at ASC 53 + LIMIT 1 54 + ) cd ON true 55 + LEFT JOIN LATERAL ( 56 + SELECT domain 57 + FROM domains 58 + WHERE did = s.did 59 + AND rkey = s.rkey 60 + ORDER BY created_at ASC 61 + LIMIT 1 62 + ) d ON cd.domain IS NULL 63 + WHERE cd.domain IS NOT NULL OR d.domain IS NOT NULL 64 + ORDER BY s.created_at DESC 65 + ` 66 + 67 + return (rows as DomainBackedSiteRow[]).map((row) => ({ 68 + did: row.did as string, 69 + rkey: row.rkey as string, 70 + domain: row.domain as string, 71 + domainType: row.domain_type as DomainBackedSite['domainType'], 72 + url: `https://${row.domain}`, 73 + })) 74 + } 75 + 76 + function sanitizeFilename(value: string): string { 77 + return value.replace(/[^a-z0-9_-]/gi, '_').toLowerCase() 78 + } 79 + 80 + async function screenshotSite( 81 + page: Page, 82 + site: DomainBackedSite, 83 + retries: number = MAX_RETRIES, 84 + ): Promise<ScreenshotResult> { 85 + const filename = `${sanitizeFilename(site.domain)}.png` 86 + const filepath = join(SITE_IMAGES_DIR, filename) 87 + 88 + for (let attempt = 0; attempt <= retries; attempt++) { 89 + try { 90 + await page.goto(site.url, { 91 + waitUntil: 'networkidle', 92 + timeout: TIMEOUT, 93 + }) 94 + 95 + await page.waitForTimeout(1000) 96 + 97 + await page.screenshot({ 98 + path: filepath, 99 + fullPage: false, 100 + type: 'png', 101 + }) 102 + 103 + return { success: true } 104 + } catch (error) { 105 + if (attempt < retries) { 106 + continue 107 + } 108 + 109 + return { 110 + success: false, 111 + error: error instanceof Error ? error.message : String(error), 112 + } 113 + } 114 + } 115 + 116 + return { success: false, error: 'Unknown error' } 117 + } 118 + 119 + async function main() { 120 + console.log('Starting site image poller') 121 + await mkdir(SITE_IMAGES_DIR, { recursive: true }) 122 + console.log(`Saving screenshots to ${SITE_IMAGES_DIR}`) 123 + 124 + const sites = await getDomainBackedSites() 125 + console.log(`Found ${sites.length} sites with custom domains or wisp subdomains`) 126 + 127 + if (sites.length === 0) { 128 + return 129 + } 130 + 131 + const browser = await chromium.launch({ 132 + headless: true, 133 + }) 134 + 135 + const context = await browser.newContext({ 136 + viewport: { 137 + width: VIEWPORT_WIDTH, 138 + height: VIEWPORT_HEIGHT, 139 + }, 140 + userAgent: 141 + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 WispSiteImageBot/1.0', 142 + }) 143 + 144 + const results = { 145 + success: 0, 146 + failed: 0, 147 + errors: [] as Array<{ site: string; error: string }>, 148 + } 149 + 150 + for (let i = 0; i < sites.length; i += CONCURRENCY) { 151 + const batch = sites.slice(i, i + CONCURRENCY) 152 + const batchNum = Math.floor(i / CONCURRENCY) + 1 153 + const totalBatches = Math.ceil(sites.length / CONCURRENCY) 154 + 155 + console.log(`Batch ${batchNum}/${totalBatches}: ${batch.length} sites`) 156 + 157 + const batchResults = await Promise.all( 158 + batch.map(async (site, index) => { 159 + const page = await context.newPage() 160 + const globalIndex = i + index + 1 161 + console.log(` [${globalIndex}/${sites.length}] ${site.url} (${site.domainType})`) 162 + 163 + const result = await screenshotSite(page, site) 164 + await page.close() 165 + 166 + return { site, result } 167 + }), 168 + ) 169 + 170 + for (const { site, result } of batchResults) { 171 + if (result.success) { 172 + results.success++ 173 + continue 174 + } 175 + 176 + results.failed++ 177 + results.errors.push({ 178 + site: `${site.did}/${site.rkey} (${site.domain})`, 179 + error: result.error || 'Unknown error', 180 + }) 181 + } 182 + } 183 + 184 + await browser.close() 185 + 186 + console.log(`Successful: ${results.success}`) 187 + console.log(`Failed: ${results.failed}`) 188 + 189 + if (results.errors.length > 0) { 190 + console.log('Failed sites:') 191 + for (const { site, error } of results.errors) { 192 + console.log(` - ${site}: ${error}`) 193 + } 194 + } 195 + } 196 + 197 + main().catch((error) => { 198 + console.error('Fatal error:', error) 199 + process.exit(1) 200 + })
+2 -2
bun.lock
··· 2114 2114 2115 2115 "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 2116 2116 2117 - "@wispplace/bun-firehose/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], 2117 + "@wispplace/bun-firehose/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], 2118 2118 2119 2119 "@wispplace/tiered-storage/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], 2120 2120 ··· 2304 2304 2305 2305 "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], 2306 2306 2307 - "@wispplace/bun-firehose/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], 2307 + "@wispplace/bun-firehose/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], 2308 2308 2309 2309 "@wispplace/tiered-storage/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], 2310 2310
+1
package.json
··· 26 26 "build:all": "bun run build && npm run build:hosting", 27 27 "check": "bun tsc --noEmit", 28 28 "screenshot": "bun run apps/main-app/scripts/screenshot-sites.ts", 29 + "site-images": "bun run apps/main-app/scripts/poll-site-images.ts", 29 30 "hosting:dev": "cd apps/hosting-service && npm run dev", 30 31 "hosting:start": "cd apps/hosting-service && npm run start", 31 32 "lint": "biome check .",