Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
87
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 .",