← 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(' ')}`);