← back to Dw Theme Toggle
add local web viewer (port 9769) for token paste + theme list + push
65aaf21cb62a4c9108c630eecf3630058f77e49a · 2026-05-08 08:17:49 -0700 · Steve Abrams
Files touched
Diff
commit 65aaf21cb62a4c9108c630eecf3630058f77e49a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri May 8 08:17:49 2026 -0700
add local web viewer (port 9769) for token paste + theme list + push
---
viewer.js | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 250 insertions(+)
diff --git a/viewer.js b/viewer.js
new file mode 100644
index 0000000..c258bc6
--- /dev/null
+++ b/viewer.js
@@ -0,0 +1,250 @@
+/* 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><head></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>`;
+
+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 res.end(JSON.stringify({ 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 res.end(JSON.stringify({ ok: false, error: 'non-JSON: ' + txt.slice(0,200) })); }
+ if (!r.ok) return res.end(JSON.stringify({ ok: false, error: 'HTTP ' + r.status + ': ' + (parsed.errors || JSON.stringify(parsed)).toString().slice(0,400) }));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ 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 res.end(JSON.stringify({ 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 res.end(JSON.stringify({ ok: false, error: 'non-JSON: ' + txt.slice(0,200) })); }
+ if (!r.ok) return res.end(JSON.stringify({ ok: false, error: 'HTTP ' + r.status + ': ' + (parsed.errors || JSON.stringify(parsed)).toString().slice(0,400) }));
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ ok: true, asset: parsed.asset }));
+ }
+ if (req.method === 'POST' && req.url === '/api/save-token') {
+ const { token } = JSON.parse(await readBody(req));
+ if (!token) return res.end(JSON.stringify({ 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 || '');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ 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));
← 150a0d6 initial scaffold — self-contained Shopify theme-toggle snipp
·
back to Dw Theme Toggle
·
snapshot: 1 file(s) changed, +1 new fcb360c →