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

screenshots

+7
bun.lock
···
"bun-plugin-tailwind": "^0.1.2",
"bun-types": "latest",
"esbuild": "0.26.0",
+
"playwright": "^1.49.0",
},
},
},
···
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
+
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
···
"pino-abstract-transport": ["pino-abstract-transport@1.2.0", "", { "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q=="],
"pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="],
+
+
"playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="],
+
+
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
"prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="],
+4 -2
package.json
···
"test": "bun test",
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
-
"build": "bun build --compile --target bun --outfile server src/index.ts"
+
"build": "bun build --compile --target bun --outfile server src/index.ts",
+
"screenshot": "bun run scripts/screenshot-sites.ts"
},
"dependencies": {
"@atproto/api": "^0.17.3",
···
"@types/react-dom": "^19.2.1",
"bun-plugin-tailwind": "^0.1.2",
"bun-types": "latest",
-
"esbuild": "0.26.0"
+
"esbuild": "0.26.0",
+
"playwright": "^1.49.0"
},
"module": "src/index.js",
"trustedDependencies": [
+60 -68
public/index.tsx
···
import React, { useState, useRef, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
-
import {
-
ArrowRight,
-
Shield,
-
Zap,
-
Globe,
-
Lock,
-
Code,
-
Server
-
} from 'lucide-react'
+
import { ArrowRight } from 'lucide-react'
import Layout from '@public/layouts'
import { Button } from '@public/components/ui/button'
import { Card } from '@public/components/ui/card'
···
function App() {
const [showForm, setShowForm] = useState(false)
const [checkingAuth, setCheckingAuth] = useState(true)
+
const [screenshots, setScreenshots] = useState<string[]>([])
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
···
}
checkAuth()
+
}, [])
+
+
useEffect(() => {
+
// Fetch screenshots list
+
const fetchScreenshots = async () => {
+
try {
+
const response = await fetch('/api/screenshots')
+
const data = await response.json()
+
setScreenshots(data.screenshots || [])
+
} catch (error) {
+
console.error('Failed to fetch screenshots:', error)
+
}
+
}
+
+
fetchScreenshots()
}, [])
useEffect(() => {
···
</div>
</section>
-
{/* Features Grid */}
-
<section id="features" className="container mx-auto px-4 py-20">
+
{/* Site Gallery */}
+
<section id="gallery" className="container mx-auto px-4 py-20">
<div className="text-center mb-16">
<h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance">
-
Why Wisp.place?
+
Join 80+ sites just like yours:
</h2>
-
<p className="text-xl text-muted-foreground text-balance max-w-2xl mx-auto">
-
Static site hosting that respects your ownership
-
</p>
</div>
-
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
-
{[
-
{
-
icon: Shield,
-
title: 'You Own Your Content',
-
description:
-
'Your site lives in your AT Protocol account. Move it to another service anytime, or take it offline yourself.'
-
},
-
{
-
icon: Zap,
-
title: 'CDN Performance',
-
description:
-
'We cache and serve your site from edge locations worldwide for fast load times.'
-
},
-
{
-
icon: Lock,
-
title: 'No Vendor Lock-in',
-
description:
-
'Your data stays in your account. Switch providers or self-host whenever you want.'
-
},
-
{
-
icon: Code,
-
title: 'Simple Deployment',
-
description:
-
'Upload your static files and we handle the rest. No complex configuration needed.'
-
},
-
{
-
icon: Server,
-
title: 'AT Protocol Native',
-
description:
-
'Built for the decentralized web. Your site has a verifiable identity on the network.'
-
},
-
{
-
icon: Globe,
-
title: 'Custom Domains',
-
description:
-
'Use your own domain name or a wisp.place subdomain. Your choice, either way.'
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-5xl mx-auto">
+
{screenshots.map((filename, i) => {
+
// Remove .png extension
+
const baseName = filename.replace('.png', '')
+
+
// Construct site URL from filename
+
let siteUrl: string
+
if (baseName.startsWith('sites_wisp_place_did_plc_')) {
+
// Handle format: sites_wisp_place_did_plc_{identifier}_{sitename}
+
const match = baseName.match(/^sites_wisp_place_did_plc_([a-z0-9]+)_(.+)$/)
+
if (match) {
+
const [, identifier, sitename] = match
+
siteUrl = `https://sites.wisp.place/did:plc:${identifier}/${sitename}`
+
} else {
+
siteUrl = '#'
+
}
+
} else {
+
// Handle format: domain_tld or subdomain_domain_tld
+
// Replace underscores with dots
+
siteUrl = `https://${baseName.replace(/_/g, '.')}`
}
-
].map((feature, i) => (
-
<Card
-
key={i}
-
className="p-6 hover:shadow-lg transition-shadow border-2 bg-card"
-
>
-
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center mb-4">
-
<feature.icon className="w-6 h-6 text-accent" />
-
</div>
-
<h3 className="text-xl font-semibold mb-2 text-card-foreground">
-
{feature.title}
-
</h3>
-
<p className="text-muted-foreground leading-relaxed">
-
{feature.description}
-
</p>
-
</Card>
-
))}
+
+
return (
+
<a
+
key={i}
+
href={siteUrl}
+
target="_blank"
+
rel="noopener noreferrer"
+
className="block"
+
>
+
<Card className="overflow-hidden hover:shadow-xl transition-all hover:scale-105 border-2 bg-card p-0 cursor-pointer">
+
<img
+
src={`/screenshots/${filename}`}
+
alt={`${baseName} screenshot`}
+
className="w-full h-auto object-cover aspect-video"
+
loading="lazy"
+
/>
+
</Card>
+
</a>
+
)
+
})}
</div>
</section>
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
···
+
#!/usr/bin/env bun
+
/**
+
* Screenshot Sites Script
+
*
+
* Takes screenshots of all sites in the database.
+
* Usage: bun run scripts/screenshot-sites.ts
+
*/
+
+
import { chromium } from 'playwright'
+
import { db } from '../src/lib/db'
+
import { mkdir } from 'fs/promises'
+
import { join } from 'path'
+
+
const SCREENSHOTS_DIR = join(process.cwd(), 'screenshots')
+
const VIEWPORT_WIDTH = 1920
+
const VIEWPORT_HEIGHT = 1080
+
const TIMEOUT = 10000 // 10 seconds
+
const MAX_RETRIES = 1
+
const CONCURRENCY = 10 // Number of parallel screenshots
+
+
interface Site {
+
did: string
+
rkey: string
+
}
+
+
/**
+
* Get all sites from the database
+
*/
+
async function getAllSites(): Promise<Site[]> {
+
const rows = await db`
+
SELECT did, rkey
+
FROM sites
+
ORDER BY created_at DESC
+
`
+
+
return rows as Site[]
+
}
+
+
/**
+
* Determine the URL to screenshot for a site
+
* Priority: custom domain (verified) โ†’ wisp domain โ†’ fallback to sites.wisp.place
+
*/
+
async function getSiteUrl(site: Site): Promise<string> {
+
// Check for custom domain mapped to this site
+
const customDomains = await db`
+
SELECT domain FROM custom_domains
+
WHERE did = ${site.did} AND rkey = ${site.rkey} AND verified = true
+
LIMIT 1
+
`
+
if (customDomains.length > 0) {
+
return `https://${customDomains[0].domain}`
+
}
+
+
// Check for wisp domain mapped to this site
+
const wispDomains = await db`
+
SELECT domain FROM domains
+
WHERE did = ${site.did} AND rkey = ${site.rkey}
+
LIMIT 1
+
`
+
if (wispDomains.length > 0) {
+
return `https://${wispDomains[0].domain}`
+
}
+
+
// Fallback to direct serving URL
+
return `https://sites.wisp.place/${site.did}/${site.rkey}`
+
}
+
+
/**
+
* Sanitize filename to remove invalid characters
+
*/
+
function sanitizeFilename(str: string): string {
+
return str.replace(/[^a-z0-9_-]/gi, '_').toLowerCase()
+
}
+
+
/**
+
* Take a screenshot of a site with retry logic
+
*/
+
async function screenshotSite(
+
page: any,
+
site: Site,
+
retries: number = MAX_RETRIES
+
): Promise<{ success: boolean; error?: string }> {
+
const url = await getSiteUrl(site)
+
// Use the URL as filename (remove https:// and sanitize)
+
const urlForFilename = url.replace(/^https?:\/\//, '')
+
const filename = `${sanitizeFilename(urlForFilename)}.png`
+
const filepath = join(SCREENSHOTS_DIR, filename)
+
+
for (let attempt = 0; attempt <= retries; attempt++) {
+
try {
+
// Navigate to the site
+
await page.goto(url, {
+
waitUntil: 'networkidle',
+
timeout: TIMEOUT
+
})
+
+
// Wait a bit for any dynamic content
+
await page.waitForTimeout(1000)
+
+
// Take screenshot
+
await page.screenshot({
+
path: filepath,
+
fullPage: false, // Just viewport, not full scrollable page
+
type: 'png'
+
})
+
+
return { success: true }
+
+
} catch (error) {
+
const errorMsg = error instanceof Error ? error.message : String(error)
+
+
if (attempt < retries) {
+
continue
+
}
+
+
return { success: false, error: errorMsg }
+
}
+
}
+
+
return { success: false, error: 'Unknown error' }
+
}
+
+
/**
+
* Main function
+
*/
+
async function main() {
+
console.log('๐Ÿš€ Starting site screenshot process...\n')
+
+
// Create screenshots directory if it doesn't exist
+
await mkdir(SCREENSHOTS_DIR, { recursive: true })
+
console.log(`๐Ÿ“ Screenshots will be saved to: ${SCREENSHOTS_DIR}\n`)
+
+
// Get all sites
+
console.log('๐Ÿ“Š Fetching sites from database...')
+
const sites = await getAllSites()
+
console.log(` Found ${sites.length} sites\n`)
+
+
if (sites.length === 0) {
+
console.log('No sites to screenshot. Exiting.')
+
return
+
}
+
+
// Launch browser
+
console.log('๐ŸŒ Launching browser...\n')
+
const browser = await chromium.launch({
+
headless: true
+
})
+
+
const context = await browser.newContext({
+
viewport: {
+
width: VIEWPORT_WIDTH,
+
height: VIEWPORT_HEIGHT
+
},
+
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'
+
})
+
+
// Track results
+
const results = {
+
success: 0,
+
failed: 0,
+
errors: [] as { site: string; error: string }[]
+
}
+
+
// Process sites in parallel batches
+
console.log(`๐Ÿ“ธ Screenshotting ${sites.length} sites with concurrency ${CONCURRENCY}...\n`)
+
+
for (let i = 0; i < sites.length; i += CONCURRENCY) {
+
const batch = sites.slice(i, i + CONCURRENCY)
+
const batchNum = Math.floor(i / CONCURRENCY) + 1
+
const totalBatches = Math.ceil(sites.length / CONCURRENCY)
+
+
console.log(`[Batch ${batchNum}/${totalBatches}] Processing ${batch.length} sites...`)
+
+
// Create a page for each site in the batch
+
const batchResults = await Promise.all(
+
batch.map(async (site, idx) => {
+
const page = await context.newPage()
+
const globalIdx = i + idx + 1
+
console.log(` [${globalIdx}/${sites.length}] ${site.did}/${site.rkey}`)
+
+
const result = await screenshotSite(page, site)
+
await page.close()
+
+
return { site, result }
+
})
+
)
+
+
// Aggregate results
+
for (const { site, result } of batchResults) {
+
if (result.success) {
+
results.success++
+
} else {
+
results.failed++
+
results.errors.push({
+
site: `${site.did}/${site.rkey}`,
+
error: result.error || 'Unknown error'
+
})
+
}
+
}
+
+
console.log(` Batch complete: ${batchResults.filter(r => r.result.success).length}/${batch.length} successful\n`)
+
}
+
+
// Cleanup
+
await browser.close()
+
+
// Print summary
+
console.log('โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—')
+
console.log('โ•‘ SCREENSHOT SUMMARY โ•‘')
+
console.log('โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n')
+
console.log(`Total sites: ${sites.length}`)
+
console.log(`โœ… Successful: ${results.success}`)
+
console.log(`โŒ Failed: ${results.failed}`)
+
+
if (results.errors.length > 0) {
+
console.log('\nFailed sites:')
+
for (const err of results.errors) {
+
console.log(` - ${err.site}: ${err.error}`)
+
}
+
}
+
+
console.log(`\n๐Ÿ“ Screenshots saved to: ${SCREENSHOTS_DIR}\n`)
+
}
+
+
// Run the script
+
main().catch((error) => {
+
console.error('Fatal error:', error)
+
process.exit(1)
+
})
+11
src/index.ts
···
dnsVerifier: dnsVerifierHealth
}
})
+
.get('/api/screenshots', async () => {
+
const { Glob } = await import('bun')
+
const glob = new Glob('*.png')
+
const screenshots: string[] = []
+
+
for await (const file of glob.scan('./public/screenshots')) {
+
screenshots.push(file)
+
}
+
+
return { screenshots }
+
})
.get('/api/admin/test', () => {
return { message: 'Admin routes test works!' }
})