← back to Dw Theme Toggle

viewer.js

253 lines

/* DW Theme Toggle — local web viewer for pasting a Shopify Admin token,
   listing themes, and pushing the snippet. Local-only (127.0.0.1:9769). */

const http = require('http');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');

const PORT = 9769;
const HOST = '127.0.0.1';
const SNIPPET_PATH = path.join(__dirname, 'snippets', 'theme-toggle.liquid');
const SECRETS_ENV = path.join(process.env.HOME, 'Projects/secrets-manager/.env');

function readEnvToken() {
  try {
    const raw = fs.readFileSync(SECRETS_ENV, 'utf8');
    for (const line of raw.split('\n')) {
      const m = line.match(/^(SHOPIFY_ADMIN_TOKEN|SHOPIFY_ACCESS_TOKEN)=(.*)$/);
      if (m) return m[2].trim().replace(/^["']|["']$/g, '');
    }
  } catch (e) {}
  return '';
}

function shopifyFetch(domain, token, pathPart, init = {}) {
  return fetch(`https://${domain}/admin/api/2024-10${pathPart}`, {
    ...init,
    headers: {
      'X-Shopify-Access-Token': token,
      'Content-Type': 'application/json',
      ...(init.headers || {}),
    },
  });
}

async function readBody(req) {
  return new Promise((res, rej) => {
    let buf = '';
    req.on('data', (c) => (buf += c));
    req.on('end', () => res(buf));
    req.on('error', rej);
  });
}

const HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DW Theme Toggle — Pusher</title>
<style>
  :root { color-scheme: dark; }
  * { box-sizing: border-box; }
  html, body { margin: 0; background: #0a0a0a; color: #f5f1e8; font: 15px/1.5 -apple-system, "SF Pro Text", system-ui, sans-serif; min-height: 100vh; }
  .wrap { max-width: 760px; margin: 0 auto; padding: 36px 22px 90px; }
  h1 { font-weight: 300; font-size: 28px; margin: 0 0 6px; letter-spacing: 0.01em; }
  h1 .dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; background: #DDAB1E; margin-right: 10px; vertical-align: middle; }
  p.sub { color: rgba(245,241,232,0.6); margin: 0 0 30px; }
  .card { background: rgba(245,241,232,0.04); border: 1px solid rgba(245,241,232,0.08); border-radius: 4px; padding: 22px 24px; margin-bottom: 20px; }
  label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(245,241,232,0.55); margin-bottom: 6px; }
  input[type=text], input[type=password] { width: 100%; background: #141414; color: #f5f1e8; border: 1px solid rgba(245,241,232,0.12); border-radius: 3px; padding: 11px 13px; font: inherit; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; }
  input:focus { outline: 2px solid #DDAB1E; outline-offset: 1px; border-color: #DDAB1E; }
  button { background: #f5f1e8; color: #0a0a0a; border: 0; border-radius: 3px; padding: 12px 22px; font: inherit; font-weight: 500; cursor: pointer; transition: background 200ms, transform 100ms; }
  button:hover { background: #fff; }
  button:active { transform: scale(0.98); }
  button:disabled { opacity: 0.4; cursor: not-allowed; }
  button.secondary { background: rgba(245,241,232,0.08); color: #f5f1e8; border: 1px solid rgba(245,241,232,0.18); }
  button.secondary:hover { background: rgba(245,241,232,0.16); }
  .row { display: flex; gap: 12px; align-items: end; }
  .row > .grow { flex: 1; }
  .theme-item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid rgba(245,241,232,0.08); border-radius: 3px; margin-bottom: 6px; cursor: pointer; }
  .theme-item:hover { background: rgba(245,241,232,0.04); }
  .theme-item.selected { border-color: #DDAB1E; background: rgba(221,171,30,0.06); }
  .theme-item input { accent-color: #DDAB1E; }
  .theme-item .role { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #DDAB1E; padding: 2px 8px; border: 1px solid rgba(221,171,30,0.4); border-radius: 999px; }
  .theme-item .role.unpublished { color: rgba(245,241,232,0.5); border-color: rgba(245,241,232,0.18); }
  .status { padding: 12px 14px; margin-top: 14px; border-radius: 3px; font-size: 13px; line-height: 1.55; }
  .status.ok    { background: rgba(56,142,60,0.16); border: 1px solid rgba(56,142,60,0.45); color: #c8e6c9; }
  .status.err   { background: rgba(220,53,69,0.14); border: 1px solid rgba(220,53,69,0.4); color: #f8c7cd; }
  .status.info  { background: rgba(245,241,232,0.05); border: 1px solid rgba(245,241,232,0.10); color: #f5f1e8; }
  pre { background: #141414; border: 1px solid rgba(245,241,232,0.08); border-radius: 3px; padding: 12px 14px; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12px; color: rgba(245,241,232,0.85); white-space: pre-wrap; word-break: break-word; }
  a { color: #DDAB1E; }
  .hide { display: none !important; }
  .small { font-size: 12px; color: rgba(245,241,232,0.55); }
</style>
</head>
<body>
  <div class="wrap">
    <h1><span class="dot"></span>DW Theme Toggle Pusher</h1>
    <p class="sub">Paste your Shopify Admin API token, pick a theme, push the snippet. Local-only — never leaves 127.0.0.1.</p>

    <div class="card">
      <label>Store domain</label>
      <input id="domain" type="text" value="designer-laboratory-sandbox.myshopify.com" />

      <div style="margin-top:14px"></div>
      <label>Shopify Admin API token (shpat_…)</label>
      <input id="token" type="password" placeholder="shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autocomplete="off" />
      <div class="small" style="margin-top:6px">
        Token must have <code>read_themes</code> + <code>write_themes</code> scope. Grant via Shopify Admin → Apps and sales channels → Develop apps → [your app] → Configuration.
      </div>

      <div style="margin-top:18px" class="row">
        <button id="btn-validate">Validate & list themes</button>
        <button id="btn-save-token" class="secondary" title="Save token to ~/Projects/secrets-manager/.env via /secrets">Save token to /secrets</button>
      </div>
      <div id="validate-status" class="status hide"></div>
    </div>

    <div id="themes-card" class="card hide">
      <label>Pick a target theme</label>
      <div id="themes-list"></div>
      <div style="margin-top:18px">
        <button id="btn-push">Push snippet to selected theme</button>
      </div>
      <div id="push-status" class="status hide"></div>
    </div>

    <div class="card">
      <label>Snippet preview</label>
      <pre id="snippet-preview">loading…</pre>
      <div class="small" style="margin-top:6px">After push, add <code>{% render 'theme-toggle' %}</code> to <code>layout/theme.liquid</code> just inside <code>&lt;head&gt;</code>.</div>
    </div>
  </div>

<script>
  const $ = (s) => document.querySelector(s);
  const setStatus = (el, kind, text) => { el.className = 'status ' + kind; el.textContent = text; el.classList.remove('hide'); };
  let selectedThemeId = null;

  fetch('/api/snippet').then(r => r.text()).then(t => { $('#snippet-preview').textContent = t.slice(0, 1200) + (t.length > 1200 ? '\\n\\n... (' + (t.length - 1200) + ' more chars)' : ''); });
  fetch('/api/loaded-token').then(r => r.json()).then(j => { if (j.last4) $('#token').placeholder = 'Loaded from /secrets — last 4: ' + j.last4 + ' (paste new value to override)'; });

  $('#btn-validate').addEventListener('click', async () => {
    const domain = $('#domain').value.trim();
    const token = $('#token').value.trim();
    const status = $('#validate-status');
    setStatus(status, 'info', 'Validating…');
    const r = await fetch('/api/themes', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({domain, token}) });
    const j = await r.json();
    if (!j.ok) { setStatus(status, 'err', 'Failed: ' + j.error); $('#themes-card').classList.add('hide'); return; }
    setStatus(status, 'ok', 'Listed ' + j.themes.length + ' theme(s).');
    const list = $('#themes-list');
    list.innerHTML = '';
    j.themes.forEach((t, i) => {
      const id = 'th-' + t.id;
      const div = document.createElement('label');
      div.className = 'theme-item' + (t.role === 'main' ? ' selected' : '');
      div.htmlFor = id;
      div.innerHTML = '<input type="radio" name="theme" id="' + id + '" value="' + t.id + '"' + (t.role === 'main' ? ' checked' : '') + '>' +
        '<div style="flex:1"><strong>' + t.name + '</strong><div class="small">id: ' + t.id + '</div></div>' +
        '<span class="role ' + (t.role === 'main' ? '' : 'unpublished') + '">' + t.role + '</span>';
      div.querySelector('input').addEventListener('change', e => {
        selectedThemeId = e.target.value;
        document.querySelectorAll('.theme-item').forEach(x => x.classList.remove('selected'));
        div.classList.add('selected');
      });
      if (t.role === 'main') selectedThemeId = String(t.id);
      list.appendChild(div);
    });
    $('#themes-card').classList.remove('hide');
  });

  $('#btn-push').addEventListener('click', async () => {
    const domain = $('#domain').value.trim();
    const token = $('#token').value.trim();
    const status = $('#push-status');
    if (!selectedThemeId) { setStatus(status, 'err', 'Pick a theme first.'); return; }
    setStatus(status, 'info', 'Uploading snippet to theme ' + selectedThemeId + '…');
    const r = await fetch('/api/push', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({domain, token, themeId: selectedThemeId}) });
    const j = await r.json();
    if (!j.ok) { setStatus(status, 'err', 'Push failed: ' + j.error); return; }
    setStatus(status, 'ok', 'Snippet uploaded as snippets/theme-toggle.liquid on theme ' + selectedThemeId + '.\\n\\nNext: open layout/theme.liquid in Shopify code editor and add  {% render \\'theme-toggle\\' %}  inside <head>.\\n\\nPreview URL: https://' + domain + '?preview_theme_id=' + selectedThemeId);
  });

  $('#btn-save-token').addEventListener('click', async () => {
    const token = $('#token').value.trim();
    const status = $('#validate-status');
    if (!token) { setStatus(status, 'err', 'Paste a token first.'); return; }
    setStatus(status, 'info', 'Saving token via /secrets…');
    const r = await fetch('/api/save-token', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({token}) });
    const j = await r.json();
    if (!j.ok) { setStatus(status, 'err', 'Save failed: ' + j.error); return; }
    setStatus(status, 'ok', 'Saved. Last 4: ' + j.last4 + '. Routes fan-out: ' + (j.routes || 'master only').slice(0, 200));
  });
</script>
</body>
</html>`;

function sendJSON(res, status, obj) {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(obj));
}

const server = http.createServer(async (req, res) => {
  try {
    if (req.url === '/' || req.url === '/index.html') {
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      return res.end(HTML);
    }
    if (req.url === '/api/snippet') {
      const body = fs.readFileSync(SNIPPET_PATH, 'utf8');
      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
      return res.end(body);
    }
    if (req.url === '/api/loaded-token') {
      const tok = readEnvToken();
      res.writeHead(200, { 'Content-Type': 'application/json' });
      return res.end(JSON.stringify({ last4: tok ? tok.slice(-4) : '' }));
    }
    if (req.method === 'POST' && req.url === '/api/themes') {
      const { domain, token } = JSON.parse(await readBody(req));
      if (!domain || !token) return sendJSON(res, 400, { ok: false, error: 'domain and token required' });
      const r = await shopifyFetch(domain, token, '/themes.json');
      const txt = await r.text();
      let parsed; try { parsed = JSON.parse(txt); } catch { return sendJSON(res, 502, { ok: false, error: 'non-JSON: ' + txt.slice(0,200) }); }
      if (!r.ok) return sendJSON(res, r.status, { ok: false, error: 'HTTP ' + r.status + ': ' + (parsed.errors || JSON.stringify(parsed)).toString().slice(0,400) });
      return sendJSON(res, 200, { ok: true, themes: (parsed.themes || []).map(t => ({ id: t.id, role: t.role, name: t.name })) });
    }
    if (req.method === 'POST' && req.url === '/api/push') {
      const { domain, token, themeId } = JSON.parse(await readBody(req));
      if (!domain || !token || !themeId) return sendJSON(res, 400, { ok: false, error: 'domain, token, themeId required' });
      const snippetBody = fs.readFileSync(SNIPPET_PATH, 'utf8');
      const r = await shopifyFetch(domain, token, '/themes/' + themeId + '/assets.json', {
        method: 'PUT',
        body: JSON.stringify({ asset: { key: 'snippets/theme-toggle.liquid', value: snippetBody } }),
      });
      const txt = await r.text();
      let parsed; try { parsed = JSON.parse(txt); } catch { return sendJSON(res, 502, { ok: false, error: 'non-JSON: ' + txt.slice(0,200) }); }
      if (!r.ok) return sendJSON(res, r.status, { ok: false, error: 'HTTP ' + r.status + ': ' + (parsed.errors || JSON.stringify(parsed)).toString().slice(0,400) });
      return sendJSON(res, 200, { ok: true, asset: parsed.asset });
    }
    if (req.method === 'POST' && req.url === '/api/save-token') {
      const { token } = JSON.parse(await readBody(req));
      if (!token) return sendJSON(res, 400, { ok: false, error: 'token required' });
      const last4 = token.slice(-4);
      const cli = path.join(process.env.HOME, 'Projects/secrets-manager/cli.js');
      exec('node ' + JSON.stringify(cli) + ' add SHOPIFY_ADMIN_TOKEN ' + JSON.stringify(token), { timeout: 30000 }, (err, stdout, stderr) => {
        const out = (stdout || '') + (stderr || '');
        sendJSON(res, err ? 500 : 200, { ok: !err, last4, routes: out.slice(0, 600), error: err ? err.message : null });
      });
      return;
    }
    res.writeHead(404);
    res.end('not found');
  } catch (e) {
    res.writeHead(500, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ ok: false, error: e.message }));
  }
});

server.listen(PORT, HOST, () => console.log('[' + new Date().toISOString() + '] dw-theme-toggle viewer on http://' + HOST + ':' + PORT));