← back to Sdcc Images

server.js

182 lines

/**
 * SDCC Images — local admin-only gallery for Steve's Student Debt Crisis images.
 *  - Source: Steve's personal Drive (steve-personal account via George tailnet)
 *  - Originals stay in Drive — viewer embeds Drive thumbnails + links to originals
 *  - Basic Auth: admin / DWSecure2024!
 *  - 10 themed views, one per Theme Factory preset
 *
 * Routes:
 *   GET /                  → theme picker (lists all 10)
 *   GET /theme/:slug       → themed gallery
 *   GET /api/files         → JSON of files
 *   GET /api/themes        → JSON of themes
 *
 * Port: 9824 (moved from 9821 to avoid collision with stayclaim/pastdoor)
 */
const express = require('express');
const helmet = require('helmet');
const basicAuth = require('express-basic-auth');
const path = require('path');
const fs = require('fs');

const themes = require('./themes');

const PORT = 9824;
const DATA_FILE = path.join(__dirname, 'data', 'files.json');

const app = express();

// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
// Public (no-auth) routes for Figma capture flow — only render themed pages with capture script
app.get('/capture/:slug', (req, res) => {
  const html = renderTheme(req.params.slug, /*captureMode*/ true);
  if (!html) return res.status(404).send('Unknown theme');
  res.set('Content-Type','text/html').send(html);
});

// Everything else requires admin auth
app.use(basicAuth({
  users: { 'admin': 'DWSecure2024!' },
  challenge: true,
  realm: 'SDCC Images',
}));
app.use(express.static(path.join(__dirname, 'public')));

function loadFiles() {
  if (!fs.existsSync(DATA_FILE)) return { files: [], generatedAt: null, query: '', fileCount: 0 };
  try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } catch { return { files: [], generatedAt: null, fileCount: 0 }; }
}

app.get('/api/files', (_req, res) => res.json(loadFiles()));
app.get('/api/themes', (_req, res) => res.json(themes));

function htmlEsc(s) { return String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }

function renderIndex() {
  const data = loadFiles();
  const cards = Object.entries(themes).map(([slug, t]) => `
    <a class="card" href="/theme/${slug}" style="background:${t.palette.bg};color:${t.palette.text};font-family:${t.fontBody}">
      <div class="swatches">
        <span style="background:${t.palette.bg}"></span>
        <span style="background:${t.palette.accent}"></span>
        <span style="background:${t.palette.accent2}"></span>
        <span style="background:${t.palette.text}"></span>
      </div>
      <h3 style="font-family:${t.fontHead};font-weight:${t.weightHead};color:${t.palette.accent}">${t.name}</h3>
      <p style="opacity:.85">${t.desc}</p>
      <span class="cta" style="border-color:${t.palette.accent};color:${t.palette.accent}">Open gallery →</span>
    </a>
  `).join('');
  return `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>SDCC Images — Theme Picker</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
  :root { color-scheme: dark; }
  * { box-sizing: border-box; }
  body { margin: 0; padding: 32px; background: #0a0c12; color: #e5e7eb; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
  header { margin-bottom: 28px; }
  header h1 { margin: 0 0 6px; font-size: 26px; font-weight: 700; }
  header p { margin: 0; color: #9ca3af; font-size: 13px; }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
  .card { display: block; padding: 22px; border-radius: 14px; text-decoration: none; transition: transform .15s, box-shadow .15s; box-shadow: 0 1px 3px rgba(0,0,0,.4); border: 1px solid rgba(255,255,255,.08); }
  .card:hover { transform: translateY(-3px); box-shadow: 0 12px 30px rgba(0,0,0,.6); }
  .card h3 { margin: 0 0 6px; font-size: 22px; }
  .card p { margin: 0 0 16px; font-size: 13px; }
  .swatches { display: flex; gap: 4px; margin-bottom: 14px; }
  .swatches span { width: 24px; height: 24px; border-radius: 50%; border: 2px solid rgba(255,255,255,.25); }
  .cta { display: inline-block; padding: 6px 12px; border: 1px solid; border-radius: 999px; font-size: 12px; font-weight: 600; }
  .meta { margin-top: 30px; padding-top: 18px; border-top: 1px solid #1a1d27; color: #6b7280; font-size: 12px; }
</style></head>
<body>
  <header>
    <h1>SDCC Images — Themed Galleries</h1>
    <p>${data.fileCount || data.files.length} images indexed from Steve's Google Drive · pick a theme to view</p>
  </header>
  <div class="grid">${cards}</div>
  <div class="meta">
    Last refresh: ${data.generatedAt ? new Date(data.generatedAt).toLocaleString() : 'never'} · Source: ${htmlEsc(data.account || 'steve-personal')} Drive · Originals stay in Drive
  </div>
</body></html>`;
}

function renderTheme(slug, captureMode = false) {
  const t = themes[slug];
  if (!t) return null;
  const data = loadFiles();
  // Capture mode: only first 60 images to keep page light + Figma capture-friendly
  if (captureMode) data.files = data.files.slice(0, 60);
  const p = t.palette;
  const cards = data.files.map(f => `
    <div class="card" data-name="${htmlEsc((f.name||'').toLowerCase())}">
      <a href="${htmlEsc(f.webViewLink)}" target="_blank" rel="noopener">
        <div class="thumb">${f.thumbnailLink ? `<img loading="lazy" src="${htmlEsc(f.thumbnailLink)}" alt="${htmlEsc(f.name||'')}">` : '—'}</div>
        <div class="name">${htmlEsc(f.name||'(untitled)')}</div>
        <div class="folder">${htmlEsc(f.parentName||'')}</div>
      </a>
    </div>
  `).join('');
  return `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>SDCC — ${htmlEsc(t.name)}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
  :root { color-scheme: ${slug === 'arctic-frost' || slug === 'botanical-garden' ? 'light' : 'dark'}; }
  * { box-sizing: border-box; }
  body { margin: 0; padding: 24px; background: ${p.bg}; color: ${p.text}; font-family: ${t.fontBody}; min-height: 100vh; }
  header { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; flex-wrap: wrap; padding-bottom: 14px; margin-bottom: 18px; border-bottom: 2px solid ${p.accent}; }
  h1 { margin: 0; font-family: ${t.fontHead}; font-weight: ${t.weightHead}; font-size: 30px; color: ${p.accent}; letter-spacing: -0.01em; }
  .subtitle { color: ${p.accent2}; font-size: 13px; opacity: 0.9; }
  .toolbar { display: flex; gap: 10px; align-items: center; }
  a.back { color: ${p.accent}; text-decoration: none; font-size: 13px; padding: 6px 12px; border: 1px solid ${p.accent}; border-radius: 999px; }
  a.back:hover { background: ${p.accent}; color: ${p.bg}; }
  input[type=search] { background: rgba(0,0,0,.18); border: 1px solid ${p.accent2}; color: ${p.text}; padding: 8px 12px; border-radius: 8px; min-width: 240px; font-family: inherit; }
  input[type=search]::placeholder { color: ${p.accent2}; opacity: 0.7; }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
  .card { background: rgba(255,255,255,.05); border: 1px solid ${p.accent2}30; border-radius: 10px; overflow: hidden; transition: transform .12s, border-color .12s, box-shadow .15s; }
  .card:hover { transform: translateY(-2px); border-color: ${p.accent}; box-shadow: 0 8px 22px rgba(0,0,0,.35); }
  .card a { display: block; color: inherit; text-decoration: none; }
  .thumb { aspect-ratio: 1 / 1; background: rgba(0,0,0,.18); display: flex; align-items: center; justify-content: center; }
  .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
  .name { padding: 8px 10px 2px; font-size: 12px; font-family: ${t.fontHead}; font-weight: ${t.weightHead}; word-break: break-word; line-height: 1.25; color: ${p.text}; }
  .folder { padding: 0 10px 8px; font-size: 10px; color: ${p.accent2}; opacity: 0.85; word-break: break-word; }
  .empty { padding: 60px 20px; text-align: center; color: ${p.accent2}; border: 1px dashed ${p.accent2}66; border-radius: 12px; }
</style></head>
<body>
  <header>
    <div>
      <h1>SDCC · ${htmlEsc(t.name)}</h1>
      <div class="subtitle">${htmlEsc(t.desc)} · ${data.files.length} images</div>
    </div>
    <div class="toolbar">
      <input id="filter" type="search" placeholder="Filter…">
      <a class="back" href="/">← All themes</a>
    </div>
  </header>
  ${data.files.length === 0 ? `<div class="empty">No images indexed. Run <code>node scripts/refresh.js</code></div>` : `<div class="grid" id="grid">${cards}</div>`}
  <script>
    const f = document.getElementById('filter');
    const cards = document.querySelectorAll('.card');
    if (f) f.addEventListener('input', e => {
      const q = e.target.value.trim().toLowerCase();
      cards.forEach(c => c.style.display = !q || c.dataset.name.includes(q) ? '' : 'none');
    });
  </script>
  ${captureMode ? '<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>' : ''}
</body></html>`;
}

app.get('/', (_req, res) => res.set('Content-Type','text/html').send(renderIndex()));
app.get('/theme/:slug', (req, res) => {
  const html = renderTheme(req.params.slug);
  if (!html) return res.status(404).send('Unknown theme');
  res.set('Content-Type','text/html').send(html);
});

app.listen(PORT, '127.0.0.1', () => {
  console.log(`SDCC Images on http://localhost:${PORT}`);
  console.log(`Login: admin / DWSecure2024!`);
  console.log(`Themes: ${Object.keys(themes).join(', ')}`);
});