← back to Wallpaper Tour
build-index.mjs
243 lines
#!/usr/bin/env node
// Build a clickable index page of all wallpaper/wallcover/walls projects:
// • public URL liveness (HEAD with 6s timeout)
// • local pm2 port if managed
// • thumbnail (first frame of the captured clip if available)
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECTS = path.join(os.homedir(), 'Projects');
// 1) Discover dirs
const dirs = fs.readdirSync(PROJECTS, { withFileTypes: true })
.filter(d => d.isDirectory() && /wallpaper|wallcover|walls/i.test(d.name))
.map(d => d.name).sort();
// 2) pm2 lookup
const pm2 = await new Promise((res) => {
execFile('pm2', ['jlist'], { maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
if (err) return res([]);
try { res(JSON.parse(stdout)); } catch { res([]); }
});
});
const pm2ByName = new Map(pm2.map(p => [p.name, p]));
// 3) HEAD each candidate URL
async function head(url) {
const t0 = Date.now();
try {
const r = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(6000), redirect: 'manual' });
return { ok: r.status < 500, status: r.status, ms: Date.now() - t0 };
} catch (e) {
return { ok: false, status: 0, ms: Date.now() - t0, error: e.code || (e.name === 'TimeoutError' ? 'timeout' : 'err') };
}
}
// concurrent HEAD pool
async function pmap(items, fn, limit = 8) {
const out = new Array(items.length); let i = 0;
await Promise.all(Array.from({ length: limit }, async () => {
while (true) {
const idx = i++; if (idx >= items.length) return;
out[idx] = await fn(items[idx], idx);
}
}));
return out;
}
console.log(`Checking ${dirs.length} sites…`);
const rows = await pmap(dirs, async (name) => {
const url = `https://${name.replace(/[^a-z0-9-]/gi, '')}.com`;
const pm2p = pm2ByName.get(name);
const port = pm2p?.pm2_env?.PORT ?? pm2p?.pm2_env?.env?.PORT ?? null;
const status = pm2p?.pm2_env?.status ?? null;
const probe = await head(url);
return {
name, public_url: url,
pm2: !!pm2p, pm2_status: status, port,
public_live: probe.ok, public_status: probe.status, public_ms: probe.ms,
};
});
// 4) Check for thumbnails (first frame of captured clip via ffmpeg)
const clipsDir = path.join(__dirname, 'clips');
const thumbDir = path.join(__dirname, 'thumbs');
fs.mkdirSync(thumbDir, { recursive: true });
const existingThumbs = new Set(fs.existsSync(thumbDir) ? fs.readdirSync(thumbDir) : []);
for (const row of rows) {
const clipMatch = fs.existsSync(clipsDir)
? fs.readdirSync(clipsDir).find(f => f.endsWith(`-${row.name}.webm`))
: null;
if (clipMatch) {
const thumbName = `${row.name}.jpg`;
if (!existingThumbs.has(thumbName)) {
const src = path.join(clipsDir, clipMatch);
const dst = path.join(thumbDir, thumbName);
await new Promise((res) => {
execFile('ffmpeg', ['-y', '-ss', '2', '-i', src, '-vframes', '1', '-vf', 'scale=400:-1', '-q:v', '4', dst],
{ timeout: 10000 }, () => res());
});
}
if (fs.existsSync(path.join(thumbDir, thumbName))) row.thumb = `thumbs/${thumbName}`;
}
}
// 5) Categorize
const cat = (n) => {
if (/^\d{4}s/.test(n)) return 'era';
if (/contract|hospital|hotel|healthcar|restaurant|architect/.test(n)) return 'commercial';
if (/silk|cork|linen|jute|suede|raffia|mica|metallic|silverleaf|goldleaf|grasscloth|natural|recycled|textile|fabric|stringwallpaper|hyggewallpaper/.test(n)) return 'material';
if (/glass|glitter|embroidered|flocked|mylar|pastel|aged|restoration/.test(n)) return 'finish';
if (/wallpaper(dictionary|definitions|glossary|history|wednesday|sback|estimator|contractor|distributors|canada)/.test(n) || /^bestwallpaperplace|wallsandfabrics$/.test(n)) return 'editorial';
if (/madagascar|carmel|hollywood/.test(n)) return 'place';
return 'other';
};
rows.forEach(r => r.category = cat(r.name));
rows.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
// 6) Render
const counts = rows.reduce((a, r) => (a[r.category] = (a[r.category] || 0) + 1, a), {});
const live = rows.filter(r => r.public_live).length;
const pm2Count = rows.filter(r => r.pm2).length;
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>DW Wallpaper Fleet — ${rows.length} sites</title>
<style>
:root{--bg:#0a0a0c;--panel:#15151a;--rule:#26262e;--ink:#eee6d8;--mute:#8c8680;--gold:#c9a96a;--ok:#7ad27a;--bad:#e26d6d;}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,'SF Pro Text',sans-serif}
header{padding:24px 32px;border-bottom:1px solid var(--rule);background:var(--panel);display:flex;justify-content:space-between;align-items:baseline}
h1{margin:0;font:300 32px/1 'SF Pro Display',serif;letter-spacing:.04em}
h1 b{color:var(--gold);font-weight:500}
.stats{font-family:'SF Mono',monospace;font-size:12px;color:var(--mute);display:flex;gap:18px}
.stats b{color:var(--ink);font-weight:600;font-size:15px;letter-spacing:0}
.cat-tabs{padding:14px 32px;display:flex;gap:8px;border-bottom:1px solid var(--rule);background:var(--panel);overflow-x:auto;align-items:center;flex-wrap:wrap}
.tab{background:transparent;border:1px solid var(--rule);color:var(--ink);padding:6px 12px;font:600 11px 'SF Pro Text';text-transform:uppercase;letter-spacing:.1em;border-radius:3px;cursor:pointer}
.tab.active{background:var(--gold);color:#000;border-color:var(--gold)}
.divider{width:1px;height:24px;background:var(--rule);margin:0 6px}
.range{display:flex;align-items:center;gap:10px;font:600 11px 'SF Pro Text';text-transform:uppercase;letter-spacing:.1em;color:var(--mute)}
.range input[type="number"]{width:50px;background:#0e0e10;border:1px solid var(--rule);color:var(--ink);padding:5px 6px;border-radius:3px;font:600 12px 'SF Mono',monospace;text-align:center}
.range input[type="number"]:focus{outline:none;border-color:var(--gold)}
.range .dash{color:var(--mute)}
.range-presets{display:flex;gap:4px}
.range-presets button{background:transparent;border:1px solid var(--rule);color:var(--mute);padding:5px 9px;font:600 10px 'SF Mono',monospace;border-radius:2px;cursor:pointer;text-transform:uppercase;letter-spacing:.06em}
.range-presets button:hover{color:var(--gold);border-color:var(--gold)}
.range-presets button.active{background:var(--gold);color:#000;border-color:var(--gold)}
.grid{padding:24px 32px;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px}
.card{background:var(--panel);border:1px solid var(--rule);border-radius:6px;overflow:hidden;text-decoration:none;color:inherit;transition:border-color .15s,transform .15s}
.card:hover{border-color:var(--gold);transform:translateY(-2px)}
.thumb{aspect-ratio:16/9;background:#0e0e10;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}
.thumb img{width:100%;height:100%;object-fit:cover}
.thumb .placeholder{color:var(--mute);font:300 32px 'SF Pro Display',serif}
.meta{padding:12px 14px}
.name{font:500 14px 'SF Pro Display';letter-spacing:.01em;margin:0 0 4px;display:flex;justify-content:space-between;align-items:center;gap:6px}
.sub{font:400 11px 'SF Mono',monospace;color:var(--mute);letter-spacing:.02em;display:flex;justify-content:space-between}
.pill{font:600 9px/1 'SF Mono',monospace;text-transform:uppercase;letter-spacing:.08em;padding:3px 5px;border-radius:2px;flex-shrink:0}
.pill.ok{background:rgba(122,210,122,.12);color:var(--ok);border:1px solid rgba(122,210,122,.3)}
.pill.bad{background:rgba(226,109,109,.12);color:var(--bad);border:1px solid rgba(226,109,109,.3)}
.footer{padding:18px 32px;color:var(--mute);font-size:11px;text-align:center;border-top:1px solid var(--rule);font-family:'SF Mono',monospace}
.hidden{display:none}
</style>
</head>
<body>
<header>
<h1>DW <b>WALLPAPER FLEET</b></h1>
<div class="stats">
<span><b>${rows.length}</b> sites</span>
<span><b>${live}</b> live public</span>
<span><b>${pm2Count}</b> pm2-managed</span>
</div>
</header>
<div class="cat-tabs">
<button class="tab active" data-cat="all">All (${rows.length})</button>
${Object.entries(counts).sort().map(([c, n]) => `<button class="tab" data-cat="${c}">${c} (${n})</button>`).join('')}
<div class="divider"></div>
<div class="range">
<span>Range</span>
<input type="number" id="rStart" min="1" max="${rows.length}" value="1">
<span class="dash">–</span>
<input type="number" id="rEnd" min="1" max="${rows.length}" value="${rows.length}">
<div class="range-presets">
<button data-r="1-10">1–10</button>
<button data-r="1-25">1–25</button>
<button data-r="1-50">1–50</button>
<button data-r="1-${rows.length}" class="active">All</button>
</div>
<span id="rCount" style="color:var(--gold);font-family:'SF Mono',monospace">${rows.length} shown</span>
</div>
</div>
<div class="grid" id="grid">
${rows.map(r => ` <a class="card" data-cat="${r.category}" href="${r.public_url}" target="_blank">
<div class="thumb">
${r.thumb ? `<img src="${r.thumb}" loading="lazy" alt="${r.name}">` : `<span class="placeholder">${r.name.slice(0,1).toUpperCase()}</span>`}
</div>
<div class="meta">
<div class="name">${r.name} <span class="pill ${r.public_live?'ok':'bad'}">${r.public_live?'LIVE':'DOWN'}</span></div>
<div class="sub"><span>${r.public_url.replace('https://','')}</span><span>${r.port?(':'+r.port):''}</span></div>
</div>
</a>`).join('\n')}
</div>
<div class="footer">~/Projects/wallpaper-tour/index.html · ${new Date().toISOString()} · sorted by ${Object.keys(counts).sort().join(' · ')}</div>
<script>
const TOTAL = ${rows.length};
const state = { cat: 'all', start: 1, end: TOTAL };
function applyFilters() {
const cards = [...document.querySelectorAll('.card')];
// First, get cards matching category
const matchingCat = cards.filter(c => state.cat === 'all' || c.dataset.cat === state.cat);
// Then, slice by range (1-indexed inclusive)
const visible = new Set(matchingCat.slice(state.start - 1, state.end));
for (const c of cards) c.classList.toggle('hidden', !visible.has(c));
document.getElementById('rCount').textContent = visible.size + ' shown';
}
for (const t of document.querySelectorAll('.tab')) {
t.onclick = () => {
document.querySelectorAll('.tab').forEach(x => x.classList.toggle('active', x === t));
state.cat = t.dataset.cat;
applyFilters();
};
}
const rStart = document.getElementById('rStart');
const rEnd = document.getElementById('rEnd');
function readRange() {
state.start = Math.max(1, Math.min(TOTAL, parseInt(rStart.value) || 1));
state.end = Math.max(state.start, Math.min(TOTAL, parseInt(rEnd.value) || TOTAL));
rStart.value = state.start; rEnd.value = state.end;
// clear preset highlights when user types custom range
document.querySelectorAll('.range-presets button').forEach(b => {
b.classList.toggle('active', b.dataset.r === state.start + '-' + state.end);
});
applyFilters();
}
rStart.addEventListener('input', readRange);
rEnd.addEventListener('input', readRange);
for (const btn of document.querySelectorAll('.range-presets button')) {
btn.onclick = () => {
const [s, e] = btn.dataset.r.split('-').map(Number);
rStart.value = s; rEnd.value = e;
readRange();
};
}
</script>
</body>
</html>`;
const outPath = path.join(__dirname, 'index.html');
fs.writeFileSync(outPath, html);
fs.writeFileSync(path.join(__dirname, 'inventory.json'), JSON.stringify(rows, null, 2));
console.log(`\n✓ ${outPath}`);
console.log(` ${rows.length} sites · ${live} live · ${pm2Count} pm2`);
console.log(` categories: ${Object.entries(counts).map(([c,n])=>`${c}=${n}`).join(' ')}`);