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

screenshots

+7
bun.lock
··· 44 "bun-plugin-tailwind": "^0.1.2", 45 "bun-types": "latest", 46 "esbuild": "0.26.0", 47 }, 48 }, 49 }, ··· 524 525 "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], 526 527 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 528 529 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], ··· 627 "pino-abstract-transport": ["pino-abstract-transport@1.2.0", "", { "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q=="], 628 629 "pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="], 630 631 "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], 632
··· 44 "bun-plugin-tailwind": "^0.1.2", 45 "bun-types": "latest", 46 "esbuild": "0.26.0", 47 + "playwright": "^1.49.0", 48 }, 49 }, 50 }, ··· 525 526 "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], 527 528 + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], 529 + 530 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 531 532 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], ··· 630 "pino-abstract-transport": ["pino-abstract-transport@1.2.0", "", { "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q=="], 631 632 "pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="], 633 + 634 + "playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="], 635 + 636 + "playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="], 637 638 "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], 639
+4 -2
package.json
··· 5 "test": "bun test", 6 "dev": "bun run --watch src/index.ts", 7 "start": "bun run src/index.ts", 8 - "build": "bun build --compile --target bun --outfile server src/index.ts" 9 }, 10 "dependencies": { 11 "@atproto/api": "^0.17.3", ··· 46 "@types/react-dom": "^19.2.1", 47 "bun-plugin-tailwind": "^0.1.2", 48 "bun-types": "latest", 49 - "esbuild": "0.26.0" 50 }, 51 "module": "src/index.js", 52 "trustedDependencies": [
··· 5 "test": "bun test", 6 "dev": "bun run --watch src/index.ts", 7 "start": "bun run src/index.ts", 8 + "build": "bun build --compile --target bun --outfile server src/index.ts", 9 + "screenshot": "bun run scripts/screenshot-sites.ts" 10 }, 11 "dependencies": { 12 "@atproto/api": "^0.17.3", ··· 47 "@types/react-dom": "^19.2.1", 48 "bun-plugin-tailwind": "^0.1.2", 49 "bun-types": "latest", 50 + "esbuild": "0.26.0", 51 + "playwright": "^1.49.0" 52 }, 53 "module": "src/index.js", 54 "trustedDependencies": [
+60 -68
public/index.tsx
··· 1 import React, { useState, useRef, useEffect } from 'react' 2 import { createRoot } from 'react-dom/client' 3 - import { 4 - ArrowRight, 5 - Shield, 6 - Zap, 7 - Globe, 8 - Lock, 9 - Code, 10 - Server 11 - } from 'lucide-react' 12 import Layout from '@public/layouts' 13 import { Button } from '@public/components/ui/button' 14 import { Card } from '@public/components/ui/card' ··· 306 function App() { 307 const [showForm, setShowForm] = useState(false) 308 const [checkingAuth, setCheckingAuth] = useState(true) 309 const inputRef = useRef<HTMLInputElement>(null) 310 311 useEffect(() => { ··· 333 } 334 335 checkAuth() 336 }, []) 337 338 useEffect(() => { ··· 553 </div> 554 </section> 555 556 - {/* Features Grid */} 557 - <section id="features" className="container mx-auto px-4 py-20"> 558 <div className="text-center mb-16"> 559 <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 560 - Why Wisp.place? 561 </h2> 562 - <p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto"> 563 - Static site hosting that respects your ownership 564 - </p> 565 </div> 566 567 - <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"> 568 - {[ 569 - { 570 - icon: Shield, 571 - title: 'You Own Your Content', 572 - description: 573 - 'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.' 574 - }, 575 - { 576 - icon: Zap, 577 - title: 'CDN Performance', 578 - description: 579 - 'We cache and serve your site from edge locations worldwide for fast load times.' 580 - }, 581 - { 582 - icon: Lock, 583 - title: 'No Vendor Lock-in', 584 - description: 585 - 'Your data stays in your account. Switch providers or self-host whenever you want.' 586 - }, 587 - { 588 - icon: Code, 589 - title: 'Simple Deployment', 590 - description: 591 - 'Upload your static files and we handle the rest. No complex configuration needed.' 592 - }, 593 - { 594 - icon: Server, 595 - title: 'AT Protocol Native', 596 - description: 597 - 'Built for the decentralized web. Your site has a verifiable identity on the network.' 598 - }, 599 - { 600 - icon: Globe, 601 - title: 'Custom Domains', 602 - description: 603 - 'Use your own domain name or a wisp.place subdomain. Your choice, either way.' 604 } 605 - ].map((feature, i) => ( 606 - <Card 607 - key={i} 608 - className="p-6 hover:shadow-lg transition-shadow border-2 bg-card" 609 - > 610 - <div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4"> 611 - <feature.icon className="w-6 h-6 text-accent" /> 612 - </div> 613 - <h3 className="text-xl font-semibold mb-2 text-card-foreground"> 614 - {feature.title} 615 - </h3> 616 - <p className="text-muted-foreground leading-relaxed"> 617 - {feature.description} 618 - </p> 619 - </Card> 620 - ))} 621 </div> 622 </section> 623
··· 1 import React, { useState, useRef, useEffect } from 'react' 2 import { createRoot } from 'react-dom/client' 3 + import { ArrowRight } from 'lucide-react' 4 import Layout from '@public/layouts' 5 import { Button } from '@public/components/ui/button' 6 import { Card } from '@public/components/ui/card' ··· 298 function App() { 299 const [showForm, setShowForm] = useState(false) 300 const [checkingAuth, setCheckingAuth] = useState(true) 301 + const [screenshots, setScreenshots] = useState<string[]>([]) 302 const inputRef = useRef<HTMLInputElement>(null) 303 304 useEffect(() => { ··· 326 } 327 328 checkAuth() 329 + }, []) 330 + 331 + useEffect(() => { 332 + // Fetch screenshots list 333 + const fetchScreenshots = async () => { 334 + try { 335 + const response = await fetch('/api/screenshots') 336 + const data = await response.json() 337 + setScreenshots(data.screenshots || []) 338 + } catch (error) { 339 + console.error('Failed to fetch screenshots:', error) 340 + } 341 + } 342 + 343 + fetchScreenshots() 344 }, []) 345 346 useEffect(() => { ··· 561 </div> 562 </section> 563 564 + {/* Site Gallery */} 565 + <section id="gallery" className="container mx-auto px-4 py-20"> 566 <div className="text-center mb-16"> 567 <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 568 + Join 80+ sites just like yours: 569 </h2> 570 </div> 571 572 + <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-5xl mx-auto"> 573 + {screenshots.map((filename, i) => { 574 + // Remove .png extension 575 + const baseName = filename.replace('.png', '') 576 + 577 + // Construct site URL from filename 578 + let siteUrl: string 579 + if (baseName.startsWith('sites_wisp_place_did_plc_')) { 580 + // Handle format: sites_wisp_place_did_plc_{identifier}_{sitename} 581 + const match = baseName.match(/^sites_wisp_place_did_plc_([a-z0-9]+)_(.+)$/) 582 + if (match) { 583 + const [, identifier, sitename] = match 584 + siteUrl = `https://sites.wisp.place/did:plc:${identifier}/${sitename}` 585 + } else { 586 + siteUrl = '#' 587 + } 588 + } else { 589 + // Handle format: domain_tld or subdomain_domain_tld 590 + // Replace underscores with dots 591 + siteUrl = `https://${baseName.replace(/_/g, '.')}` 592 } 593 + 594 + return ( 595 + <a 596 + key={i} 597 + href={siteUrl} 598 + target="_blank" 599 + rel="noopener noreferrer" 600 + className="block" 601 + > 602 + <Card className="overflow-hidden hover:shadow-xl transition-all hover:scale-105 border-2 bg-card p-0 cursor-pointer"> 603 + <img 604 + src={`/screenshots/${filename}`} 605 + alt={`${baseName} screenshot`} 606 + className="w-full h-auto object-cover aspect-video" 607 + loading="lazy" 608 + /> 609 + </Card> 610 + </a> 611 + ) 612 + })} 613 </div> 614 </section> 615
public/screenshots/atproto-ui_wisp_place.png

This is a binary file and will not be displayed.

public/screenshots/avalanche_moe.png

This is a binary file and will not be displayed.

public/screenshots/brotosolar_wisp_place.png

This is a binary file and will not be displayed.

public/screenshots/erisa_wisp_place.png

This is a binary file and will not be displayed.

public/screenshots/hayden_moe.png

This is a binary file and will not be displayed.

public/screenshots/kot_pink.png

This is a binary file and will not be displayed.

public/screenshots/moover_wisp_place.png

This is a binary file and will not be displayed.

public/screenshots/nekomimi_pet.png

This is a binary file and will not be displayed.

public/screenshots/pdsls_wisp_place.png

This is a binary file and will not be displayed.

public/screenshots/plc-bench_wisp_place.png

This is a binary file and will not be displayed.

public/screenshots/rainygoo_se.png

This is a binary file and will not be displayed.

public/screenshots/rd_jbcrn_dev.png

This is a binary file and will not be displayed.

public/screenshots/sites_wisp_place_did_plc_3whdb534faiczugsz5fnohh6_rafa.png

This is a binary file and will not be displayed.

public/screenshots/sites_wisp_place_did_plc_524tuhdhh3m7li5gycdn6boe_plcbundle-watch.png

This is a binary file and will not be displayed.

public/screenshots/system_grdnsys_no.png

This is a binary file and will not be displayed.

public/screenshots/tealfm_indexx_dev.png

This is a binary file and will not be displayed.

public/screenshots/tigwyk_wisp_place.png

This is a binary file and will not be displayed.

public/screenshots/wfr_jbc_lol.png

This is a binary file and will not be displayed.

public/screenshots/wisp_jbc_lol.png

This is a binary file and will not be displayed.

public/screenshots/wisp_soverth_f5_si.png

This is a binary file and will not be displayed.

public/screenshots/www_miriscient_org.png

This is a binary file and will not be displayed.

public/screenshots/www_wlo_moe.png

This is a binary file and will not be displayed.

+229
scripts/screenshot-sites.ts
···
··· 1 + #!/usr/bin/env bun 2 + /** 3 + * Screenshot Sites Script 4 + * 5 + * Takes screenshots of all sites in the database. 6 + * Usage: bun run scripts/screenshot-sites.ts 7 + */ 8 + 9 + import { chromium } from 'playwright' 10 + import { db } from '../src/lib/db' 11 + import { mkdir } from 'fs/promises' 12 + import { join } from 'path' 13 + 14 + const SCREENSHOTS_DIR = join(process.cwd(), 'screenshots') 15 + const VIEWPORT_WIDTH = 1920 16 + const VIEWPORT_HEIGHT = 1080 17 + const TIMEOUT = 10000 // 10 seconds 18 + const MAX_RETRIES = 1 19 + const CONCURRENCY = 10 // Number of parallel screenshots 20 + 21 + interface Site { 22 + did: string 23 + rkey: string 24 + } 25 + 26 + /** 27 + * Get all sites from the database 28 + */ 29 + async function getAllSites(): Promise<Site[]> { 30 + const rows = await db` 31 + SELECT did, rkey 32 + FROM sites 33 + ORDER BY created_at DESC 34 + ` 35 + 36 + return rows as Site[] 37 + } 38 + 39 + /** 40 + * Determine the URL to screenshot for a site 41 + * Priority: custom domain (verified) → wisp domain → fallback to sites.wisp.place 42 + */ 43 + async function getSiteUrl(site: Site): Promise<string> { 44 + // Check for custom domain mapped to this site 45 + const customDomains = await db` 46 + SELECT domain FROM custom_domains 47 + WHERE did = ${site.did} AND rkey = ${site.rkey} AND verified = true 48 + LIMIT 1 49 + ` 50 + if (customDomains.length > 0) { 51 + return `https://${customDomains[0].domain}` 52 + } 53 + 54 + // Check for wisp domain mapped to this site 55 + const wispDomains = await db` 56 + SELECT domain FROM domains 57 + WHERE did = ${site.did} AND rkey = ${site.rkey} 58 + LIMIT 1 59 + ` 60 + if (wispDomains.length > 0) { 61 + return `https://${wispDomains[0].domain}` 62 + } 63 + 64 + // Fallback to direct serving URL 65 + return `https://sites.wisp.place/${site.did}/${site.rkey}` 66 + } 67 + 68 + /** 69 + * Sanitize filename to remove invalid characters 70 + */ 71 + function sanitizeFilename(str: string): string { 72 + return str.replace(/[^a-z0-9_-]/gi, '_').toLowerCase() 73 + } 74 + 75 + /** 76 + * Take a screenshot of a site with retry logic 77 + */ 78 + async function screenshotSite( 79 + page: any, 80 + site: Site, 81 + retries: number = MAX_RETRIES 82 + ): Promise<{ success: boolean; error?: string }> { 83 + const url = await getSiteUrl(site) 84 + // Use the URL as filename (remove https:// and sanitize) 85 + const urlForFilename = url.replace(/^https?:\/\//, '') 86 + const filename = `${sanitizeFilename(urlForFilename)}.png` 87 + const filepath = join(SCREENSHOTS_DIR, filename) 88 + 89 + for (let attempt = 0; attempt <= retries; attempt++) { 90 + try { 91 + // Navigate to the site 92 + await page.goto(url, { 93 + waitUntil: 'networkidle', 94 + timeout: TIMEOUT 95 + }) 96 + 97 + // Wait a bit for any dynamic content 98 + await page.waitForTimeout(1000) 99 + 100 + // Take screenshot 101 + await page.screenshot({ 102 + path: filepath, 103 + fullPage: false, // Just viewport, not full scrollable page 104 + type: 'png' 105 + }) 106 + 107 + return { success: true } 108 + 109 + } catch (error) { 110 + const errorMsg = error instanceof Error ? error.message : String(error) 111 + 112 + if (attempt < retries) { 113 + continue 114 + } 115 + 116 + return { success: false, error: errorMsg } 117 + } 118 + } 119 + 120 + return { success: false, error: 'Unknown error' } 121 + } 122 + 123 + /** 124 + * Main function 125 + */ 126 + async function main() { 127 + console.log('🚀 Starting site screenshot process...\n') 128 + 129 + // Create screenshots directory if it doesn't exist 130 + await mkdir(SCREENSHOTS_DIR, { recursive: true }) 131 + console.log(`📁 Screenshots will be saved to: ${SCREENSHOTS_DIR}\n`) 132 + 133 + // Get all sites 134 + console.log('📊 Fetching sites from database...') 135 + const sites = await getAllSites() 136 + console.log(` Found ${sites.length} sites\n`) 137 + 138 + if (sites.length === 0) { 139 + console.log('No sites to screenshot. Exiting.') 140 + return 141 + } 142 + 143 + // Launch browser 144 + console.log('🌐 Launching browser...\n') 145 + const browser = await chromium.launch({ 146 + headless: true 147 + }) 148 + 149 + const context = await browser.newContext({ 150 + viewport: { 151 + width: VIEWPORT_WIDTH, 152 + height: VIEWPORT_HEIGHT 153 + }, 154 + userAgent: '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 WispScreenshotBot/1.0' 155 + }) 156 + 157 + // Track results 158 + const results = { 159 + success: 0, 160 + failed: 0, 161 + errors: [] as { site: string; error: string }[] 162 + } 163 + 164 + // Process sites in parallel batches 165 + console.log(`📸 Screenshotting ${sites.length} sites with concurrency ${CONCURRENCY}...\n`) 166 + 167 + for (let i = 0; i < sites.length; i += CONCURRENCY) { 168 + const batch = sites.slice(i, i + CONCURRENCY) 169 + const batchNum = Math.floor(i / CONCURRENCY) + 1 170 + const totalBatches = Math.ceil(sites.length / CONCURRENCY) 171 + 172 + console.log(`[Batch ${batchNum}/${totalBatches}] Processing ${batch.length} sites...`) 173 + 174 + // Create a page for each site in the batch 175 + const batchResults = await Promise.all( 176 + batch.map(async (site, idx) => { 177 + const page = await context.newPage() 178 + const globalIdx = i + idx + 1 179 + console.log(` [${globalIdx}/${sites.length}] ${site.did}/${site.rkey}`) 180 + 181 + const result = await screenshotSite(page, site) 182 + await page.close() 183 + 184 + return { site, result } 185 + }) 186 + ) 187 + 188 + // Aggregate results 189 + for (const { site, result } of batchResults) { 190 + if (result.success) { 191 + results.success++ 192 + } else { 193 + results.failed++ 194 + results.errors.push({ 195 + site: `${site.did}/${site.rkey}`, 196 + error: result.error || 'Unknown error' 197 + }) 198 + } 199 + } 200 + 201 + console.log(` Batch complete: ${batchResults.filter(r => r.result.success).length}/${batch.length} successful\n`) 202 + } 203 + 204 + // Cleanup 205 + await browser.close() 206 + 207 + // Print summary 208 + console.log('╔════════════════════════════════════════════════════════════════╗') 209 + console.log('║ SCREENSHOT SUMMARY ║') 210 + console.log('╚════════════════════════════════════════════════════════════════╝\n') 211 + console.log(`Total sites: ${sites.length}`) 212 + console.log(`✅ Successful: ${results.success}`) 213 + console.log(`❌ Failed: ${results.failed}`) 214 + 215 + if (results.errors.length > 0) { 216 + console.log('\nFailed sites:') 217 + for (const err of results.errors) { 218 + console.log(` - ${err.site}: ${err.error}`) 219 + } 220 + } 221 + 222 + console.log(`\n📁 Screenshots saved to: ${SCREENSHOTS_DIR}\n`) 223 + } 224 + 225 + // Run the script 226 + main().catch((error) => { 227 + console.error('Fatal error:', error) 228 + process.exit(1) 229 + })
+11
src/index.ts
··· 143 dnsVerifier: dnsVerifierHealth 144 } 145 }) 146 .get('/api/admin/test', () => { 147 return { message: 'Admin routes test works!' } 148 })
··· 143 dnsVerifier: dnsVerifierHealth 144 } 145 }) 146 + .get('/api/screenshots', async () => { 147 + const { Glob } = await import('bun') 148 + const glob = new Glob('*.png') 149 + const screenshots: string[] = [] 150 + 151 + for await (const file of glob.scan('./public/screenshots')) { 152 + screenshots.push(file) 153 + } 154 + 155 + return { screenshots } 156 + }) 157 .get('/api/admin/test', () => { 158 return { message: 'Admin routes test works!' } 159 })