[object Object]

← back to Dw Marketing Reels

auto-save: 2026-07-15T14:07:06 (2 files) — public/index.html server.js

eae24363a6d72b80bed8616a19db601297afbb29 · 2026-07-15 14:07:10 -0700 · Steve Abrams

Files touched

Diff

commit eae24363a6d72b80bed8616a19db601297afbb29
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 15 14:07:10 2026 -0700

    auto-save: 2026-07-15T14:07:06 (2 files) — public/index.html server.js
---
 public/index.html | 235 +++++++++++++++++++++++++++++++++++++++++-------------
 server.js         |  13 ++-
 2 files changed, 192 insertions(+), 56 deletions(-)

diff --git a/public/index.html b/public/index.html
index e0dd198..eb5f003 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,20 +5,34 @@
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title>DW Marketing · New Arrivals Reels</title>
 <style>
-  :root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; --line:#e2dccf; --cols:4; }
+  :root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; --line:#e2dccf; --cols:4; --side:248px; }
   * { box-sizing:border-box; }
   body { margin:0; font:15px/1.5 "Helvetica Neue",system-ui,sans-serif; color:var(--ink); background:var(--cream); }
-  header { display:flex; align-items:center; gap:20px; padding:20px 28px; border-bottom:1px solid var(--line); background:#fff; position:sticky; top:0; z-index:5; }
-  header .mark { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:26px; }
-  header .mark b { color:var(--gold); font-weight:400; }
-  header .sub { font-size:12px; letter-spacing:.22em; text-transform:uppercase; color:#8a8272; }
-  header .spacer { flex:1; }
+
+  /* ── left side panel ── */
+  .side { position:fixed; top:0; left:0; bottom:0; width:var(--side); background:#fff; border-right:1px solid var(--line);
+    display:flex; flex-direction:column; padding:22px 18px; gap:22px; overflow-y:auto; z-index:20; }
+  .side .mark { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:23px; line-height:1.15; }
+  .side .mark b { color:var(--gold); font-weight:400; }
+  .side .sub { font-size:10.5px; letter-spacing:.2em; text-transform:uppercase; color:#8a8272; margin-top:4px; }
+  .side nav { display:flex; flex-direction:column; gap:2px; }
+  .side nav a { text-decoration:none; color:#5c5648; font-size:13.5px; padding:8px 10px; border-radius:8px; letter-spacing:.02em; }
+  .side nav a:hover { background:var(--cream); color:var(--ink); }
+  .side .grp { font-size:10px; letter-spacing:.2em; text-transform:uppercase; color:#a79f8d; margin:2px 0 4px 10px; }
+  .side .acts { display:flex; flex-direction:column; gap:8px; margin-top:auto; }
+  .side .acts button { width:100%; }
+
   button { font:inherit; border:1px solid var(--ink); background:var(--ink); color:#fff; padding:9px 16px; border-radius:999px; cursor:pointer; letter-spacing:.04em; }
   button.ghost { background:#fff; color:var(--ink); }
+  button.gold { background:var(--gold); border-color:var(--gold); }
   button:disabled { opacity:.5; cursor:not-allowed; }
-  main { padding:28px; max-width:1400px; margin:0 auto; }
-  h2 { font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:26px; margin:34px 0 14px; }
-  .status { font-size:13px; color:#6a6355; min-height:20px; margin:6px 0 0; }
+  button.tiny { font-size:11px; padding:5px 10px; }
+
+  .wrap { margin-left:var(--side); }
+  main { padding:28px 34px; max-width:1400px; }
+  h2 { font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:26px; margin:34px 0 14px; scroll-margin-top:20px; }
+  h2:first-of-type { margin-top:6px; }
+  .status { font-size:13px; color:#6a6355; min-height:20px; margin:6px 0 0; position:sticky; top:0; background:var(--cream); padding:8px 0; z-index:5; }
   .reels { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:20px; }
   .reel { background:#fff; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
   .reel video { width:100%; display:block; background:#000; aspect-ratio:9/16; object-fit:cover; }
@@ -33,7 +47,6 @@
   .badge.wait { background:#f6efe0; color:#8a6d2f; } .badge.err { background:#fbe6e4; color:#a3382c; }
   .cap-box { background:#faf7f0; border:1px solid var(--line); border-radius:8px; padding:8px 10px; margin:8px 0; }
   .cap-txt { font-size:12px; color:#4c463c; white-space:pre-wrap; max-height:96px; overflow:auto; }
-  button.tiny { font-size:11px; padding:5px 10px; margin-top:8px; }
   .copied { background:#e5f3e8 !important; color:#1f7a3d !important; border-color:#1f7a3d !important; }
   .controls { display:flex; gap:22px; align-items:center; flex-wrap:wrap; margin:6px 0 16px; }
   .controls label { font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; margin-right:8px; }
@@ -55,28 +68,57 @@
   .postto { display:flex; gap:8px; align-items:center; margin-top:10px; }
   .postto label { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; }
   .postto select { font-size:12.5px; padding:5px 8px; }
+
+  /* ── modals ── */
+  .overlay { position:fixed; inset:0; background:rgba(28,26,23,.44); display:none; align-items:center; justify-content:center; z-index:40; padding:20px; }
+  .overlay.open { display:flex; }
+  .modal { background:#fff; border-radius:14px; width:min(460px,100%); max-height:90vh; overflow:auto; box-shadow:0 20px 60px rgba(0,0,0,.25); }
+  .modal .mhd { display:flex; align-items:center; padding:18px 22px; border-bottom:1px solid var(--line); }
+  .modal .mhd h3 { margin:0; font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:22px; flex:1; }
+  .modal .mhd .x { background:none; border:none; color:#8a8272; font-size:22px; cursor:pointer; padding:0 4px; }
+  .modal .mbd { padding:20px 22px; display:flex; flex-direction:column; gap:14px; }
+  .modal label.fld { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; display:block; margin-bottom:5px; }
+  .modal input[type=text], .modal select { width:100%; padding:9px 11px; border:1px solid var(--line); border-radius:8px; background:#fff; font:inherit; }
+  .modal .mft { display:flex; gap:10px; justify-content:flex-end; padding:0 22px 20px; }
+  .modal .note { font-size:12px; color:#8a8272; }
+  .modal .out { font-size:12.5px; color:#4c463c; background:#faf7f0; border:1px solid var(--line); border-radius:8px; padding:10px 12px; white-space:pre-wrap; max-height:160px; overflow:auto; display:none; }
+  .modal .out.show { display:block; }
+  .modal .out a { color:var(--gold); }
 </style>
 </head>
 <body>
-<header>
+
+<!-- ── LEFT SIDE PANEL ── -->
+<aside class="side">
   <div>
     <div class="mark">Designer <b>Wallcoverings</b></div>
-    <div class="sub">Marketing · New Arrivals Reels</div>
+    <div class="sub">Marketing · Reels</div>
+  </div>
+  <nav>
+    <div class="grp">Browse</div>
+    <a href="#sec-reels">🎞 Reels</a>
+    <a href="#sec-accts">📷 Instagram accounts</a>
+    <a href="#sec-na">🆕 New Arrivals</a>
+  </nav>
+  <div class="acts">
+    <button class="gold" id="btnPublish">✦ Publish to Instagram</button>
+    <button class="ghost" id="btnAddAcct">+ Add account</button>
+    <button class="ghost" id="btnRefresh">↻ Refresh feed</button>
+    <button id="btnBuild">▶ Generate reel</button>
   </div>
-  <div class="spacer"></div>
-  <button class="ghost" id="btnRefresh">↻ Refresh feed</button>
-  <button id="btnBuild">▶ Generate reel</button>
-</header>
+</aside>
+
+<div class="wrap">
 <main>
   <div class="status" id="status"></div>
 
-  <h2>Instagram accounts <span style="font-size:13px;color:#8a8272">(choose per reel below)</span></h2>
+  <h2 id="sec-accts">Instagram accounts <span style="font-size:13px;color:#8a8272">(choose per reel below)</span></h2>
   <div class="accts" id="accts"></div>
 
-  <h2>Reels</h2>
+  <h2 id="sec-reels">Reels</h2>
   <div class="reels" id="reels"></div>
 
-  <h2>Current New Arrivals <span id="naCount" style="font-size:13px;color:#8a8272"></span></h2>
+  <h2 id="sec-na">Current New Arrivals <span id="naCount" style="font-size:13px;color:#8a8272"></span></h2>
   <div class="controls">
     <div><label>Sort</label>
       <select id="sort">
@@ -92,6 +134,40 @@
   </div>
   <div class="grid" id="grid"></div>
 </main>
+</div>
+
+<!-- ── PUBLISH MODAL ── -->
+<div class="overlay" id="ovPublish">
+  <div class="modal">
+    <div class="mhd"><h3>Publish to Instagram</h3><button class="x" data-close>×</button></div>
+    <div class="mbd">
+      <div><label class="fld">Reel</label><select id="pubReel"></select></div>
+      <div><label class="fld">Account</label><select id="pubAcct"></select></div>
+      <div class="note">Posts the reel as an Instagram Reel to the selected account. The live post only fires when the studio host is armed (<code>SOCIAL_LIVE_ARMED=1</code>); otherwise this runs a safe dry-check.</div>
+      <div class="out" id="pubOut"></div>
+    </div>
+    <div class="mft">
+      <button class="ghost" data-close>Cancel</button>
+      <button class="gold" id="pubGo">Publish now</button>
+    </div>
+  </div>
+</div>
+
+<!-- ── ADD ACCOUNT MODAL ── -->
+<div class="overlay" id="ovAcct">
+  <div class="modal">
+    <div class="mhd"><h3 id="acctTitle">Add Instagram account</h3><button class="x" data-close>×</button></div>
+    <div class="mbd">
+      <div><label class="fld">Business / account name</label><input type="text" id="acctLabel" placeholder="e.g. Phillipe Romano" /></div>
+      <div><label class="fld">Instagram handle (without @)</label><input type="text" id="acctHandle" placeholder="philipperomano" /></div>
+      <div class="note" id="acctNote">New accounts start as “needs creds” — wire the token to activate.</div>
+    </div>
+    <div class="mft">
+      <button class="ghost" data-close>Cancel</button>
+      <button class="gold" id="acctSave">Save</button>
+    </div>
+  </div>
+</div>
 
 <script>
 const $ = s => document.querySelector(s);
@@ -112,8 +188,16 @@ function pubBadges(pub){
   return `<div class="badges">${bits.join('')}</div>`;
 }
 
-// ---- instagram accounts ----
-let ACCTS = [];
+// ── modal helpers ──
+function openModal(id){ $(id).classList.add('open'); }
+function closeModal(el){ el.classList.remove('open'); }
+document.querySelectorAll('.overlay').forEach(ov => {
+  ov.addEventListener('click', e => { if (e.target === ov || e.target.hasAttribute('data-close')) closeModal(ov); });
+});
+document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.overlay.open').forEach(closeModal); });
+
+// ── instagram accounts ──
+let ACCTS = [], REELS = [];
 async function loadAccounts() {
   ACCTS = await (await fetch('api/ig-accounts')).json();
   $('#accts').innerHTML = ACCTS.map(a => `
@@ -122,35 +206,78 @@ async function loadAccounts() {
       <span>${escapeHtml(a.label)}</span>
       <span class="h">${a.handle ? '@'+escapeHtml(a.handle) : 'handle TBD'} · ${a.status==='ready'?'connected':'needs creds'}</span>
       <button class="ghost tiny edit-acct" data-id="${a.id}">✎</button>
-    </div>`).join('') +
-    `<button class="ghost tiny" id="addAcct">+ Add account</button>`;
+    </div>`).join('') || '<span style="color:#8a8272">No accounts yet.</span>';
 }
 function acctOptions(sel) {
   return ACCTS.map(a => `<option value="${a.id}" ${a.id===sel?'selected':''}>${escapeHtml(a.label)}${a.status==='ready'?'':' (needs creds)'}</option>`).join('');
 }
-$('#accts').addEventListener('click', async e => {
-  if (e.target.id === 'addAcct') {
-    const label = prompt('Business / account name (e.g. "Phillipe Romano"):'); if (!label) return;
+// edit existing account → reuse the account modal
+$('#accts').addEventListener('click', e => {
+  const ed = e.target.closest('.edit-acct'); if (!ed) return;
+  const a = ACCTS.find(x => x.id === ed.dataset.id); if (!a) return;
+  $('#acctTitle').textContent = 'Edit ' + a.label;
+  $('#acctLabel').value = a.label; $('#acctLabel').disabled = true;
+  $('#acctHandle').value = a.handle || '';
+  $('#acctSave').dataset.editId = a.id;
+  openModal('#ovAcct');
+});
+// add account button
+$('#btnAddAcct').addEventListener('click', () => {
+  $('#acctTitle').textContent = 'Add Instagram account';
+  $('#acctLabel').value = ''; $('#acctLabel').disabled = false;
+  $('#acctHandle').value = ''; delete $('#acctSave').dataset.editId;
+  openModal('#ovAcct');
+});
+// save account (add or edit)
+$('#acctSave').addEventListener('click', async () => {
+  const editId = $('#acctSave').dataset.editId;
+  const handle = $('#acctHandle').value.trim().replace(/^@/,'') || null;
+  let body;
+  if (editId) { body = { id: editId, handle }; }
+  else {
+    const label = $('#acctLabel').value.trim(); if (!label) { $('#acctLabel').focus(); return; }
     const id = label.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
-    const handle = prompt('Instagram handle (without @, blank if unknown):') || null;
-    await fetch('api/ig-accounts', { method:'POST', headers:{'content-type':'application/json'},
-      body: JSON.stringify({ id, label, handle, status:'pending-creds', note:'added from console' }) });
-    loadAccounts();
-  }
-  const ed = e.target.closest('.edit-acct');
-  if (ed) {
-    const a = ACCTS.find(x => x.id === ed.dataset.id); if (!a) return;
-    const handle = prompt(`Instagram handle for ${a.label} (without @):`, a.handle||''); if (handle===null) return;
-    await fetch('api/ig-accounts', { method:'POST', headers:{'content-type':'application/json'},
-      body: JSON.stringify({ id:a.id, handle: handle||null }) });
-    loadAccounts();
+    body = { id, label, handle, status:'pending-creds', note:'added from console' };
   }
+  await fetch('api/ig-accounts', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
+  closeModal($('#ovAcct')); loadAccounts();
+});
+
+// ── publish modal ──
+$('#btnPublish').addEventListener('click', () => {
+  if (!REELS.length) { $('#status').textContent = 'No reels to publish yet.'; return; }
+  $('#pubReel').innerHTML = REELS.map(r => `<option value="${escapeAttr(r.file)}">${escapeHtml(r.file)}</option>`).join('');
+  $('#pubAcct').innerHTML = acctOptions('dw');
+  const out = $('#pubOut'); out.className = 'out'; out.textContent = '';
+  $('#pubGo').disabled = false; $('#pubGo').textContent = 'Publish now';
+  openModal('#ovPublish');
+});
+$('#pubGo').addEventListener('click', async () => {
+  const file = $('#pubReel').value; const out = $('#pubOut');
+  $('#pubGo').disabled = true; $('#pubGo').textContent = 'Publishing…';
+  out.className = 'out show'; out.textContent = '→ starting publish for ' + file + '…';
+  const r = await fetch('api/publish', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ file }) });
+  if (!r.ok) { const j = await r.json().catch(()=>({})); out.textContent = '✗ ' + (j.error||'failed to start'); $('#pubGo').disabled=false; $('#pubGo').textContent='Publish now'; return; }
+  // poll the job log until it finishes, streaming the tail into the modal
+  const tick = async () => {
+    const st = await (await fetch('api/status')).json();
+    const tail = (st.log||'').split('\n').filter(Boolean).slice(-6).join('\n');
+    out.textContent = tail || 'working…';
+    if (st.building) { setTimeout(tick, 1500); }
+    else {
+      const link = (st.log.match(/https:\/\/www\.instagram\.com\/reel\/[\w-]+\/?/)||[])[0];
+      if (link) out.innerHTML = escapeHtml(tail).replace(link, `<a href="${link}" target="_blank" rel="noopener noreferrer">${link}</a>`);
+      $('#pubGo').disabled = false; $('#pubGo').textContent = 'Publish again';
+      loadReels();
+    }
+  };
+  tick();
 });
 
-// ---- reels gallery ----
+// ── reels gallery ──
 async function loadReels() {
-  const reels = await (await fetch('api/reels')).json();
-  $('#reels').innerHTML = reels.length ? reels.map(r => `
+  REELS = await (await fetch('api/reels')).json();
+  $('#reels').innerHTML = REELS.length ? REELS.map(r => `
     <div class="reel">
       <video src="reels/${encodeURIComponent(r.file)}" controls preload="metadata" playsinline></video>
       <div class="body">
@@ -163,27 +290,28 @@ async function loadReels() {
         ${r.caption ? `<div class="cap-box"><div class="cap-txt">${escapeHtml(r.caption)}</div>
           <button class="ghost tiny copy" data-cap="${escapeAttr(r.caption)}">Copy caption</button></div>` : ''}
         <div class="row"><a href="reels/${encodeURIComponent(r.file)}" download>Download</a>
+          <button class="ghost tiny pub-one" data-file="${escapeAttr(r.file)}">Publish ↗</button>
           <span style="font-size:12px;color:#9a917f">${r.products} products · ${r.seconds}s</span></div>
       </div>
     </div>`).join('') : '<p style="color:#8a8272">No reels yet — hit “Generate reel”.</p>';
 }
-// account picker per reel (delegated)
+// per-reel account picker + copy caption + quick-publish (delegated)
 $('#reels').addEventListener('change', async e => {
   const sel = e.target.closest('.pick-acct'); if (!sel) return;
   const r = await fetch('api/reel-account', { method:'POST', headers:{'content-type':'application/json'},
     body: JSON.stringify({ file: sel.dataset.file, accountId: sel.value }) });
   $('#status').textContent = r.ok ? `✓ ${sel.dataset.file} → ${sel.options[sel.selectedIndex].text}` : '✗ failed to set account';
 });
-// copy caption (delegated)
 $('#reels').addEventListener('click', e => {
-  const b = e.target.closest('.copy'); if (!b) return;
-  navigator.clipboard.writeText(b.dataset.cap || '').then(() => {
-    const o = b.textContent; b.textContent = '✓ Copied'; b.classList.add('copied');
-    setTimeout(() => { b.textContent = o; b.classList.remove('copied'); }, 1500);
-  });
+  const c = e.target.closest('.copy');
+  if (c) { navigator.clipboard.writeText(c.dataset.cap || '').then(() => {
+    const o = c.textContent; c.textContent = '✓ Copied'; c.classList.add('copied');
+    setTimeout(() => { c.textContent = o; c.classList.remove('copied'); }, 1500); }); return; }
+  const p = e.target.closest('.pub-one');
+  if (p) { $('#btnPublish').click(); $('#pubReel').value = p.dataset.file; }
 });
 
-// ---- new arrivals grid (sort + density, persisted) ----
+// ── new arrivals grid (sort + density, persisted) ──
 let NA = [];
 function renderGrid() {
   const s = $('#sort').value;
@@ -214,7 +342,7 @@ document.documentElement.style.setProperty('--cols', $('#density').value);
 $('#sort').addEventListener('change', e=>{ localStorage.setItem('dwr_sort',e.target.value); renderGrid(); });
 $('#density').addEventListener('input', e=>{ localStorage.setItem('dwr_density',e.target.value); document.documentElement.style.setProperty('--cols', e.target.value); });
 
-// ---- jobs ----
+// ── jobs ──
 async function poll() {
   const st = await (await fetch('api/status')).json();
   const busy = st.building;
@@ -229,14 +357,13 @@ async function job(path, label) {
 $('#btnRefresh').addEventListener('click', ()=>job('api/refresh','↻ refreshing feed…'));
 $('#btnBuild').addEventListener('click', ()=>job('api/build','▶ generating reel (≈30s)…'));
 
-// read-only public deploy hides the generation controls
+// read-only public deploy hides the generation + publish controls
 (async () => {
   try {
     const cfg = await (await fetch('api/config')).json();
     if (!cfg.allowBuild) {
-      $('#btnBuild').style.display = 'none';
-      $('#btnRefresh').style.display = 'none';
-      $('#status').textContent = 'Reels are rendered on the studio host and published here automatically.';
+      ['#btnBuild','#btnRefresh','#btnPublish'].forEach(s => $(s).style.display = 'none');
+      $('#status').textContent = 'Reels are rendered + published on the studio host.';
     }
   } catch {}
 })();
diff --git a/server.js b/server.js
index cf8fa68..bcc0db9 100644
--- a/server.js
+++ b/server.js
@@ -30,13 +30,13 @@ function authed(req) {
   return u === USER && p === PASS;
 }
 
-function run(script, res) {
+function run(script, res, extraArgs = []) {
   if (!ALLOW_BUILD) { res.writeHead(400, { 'content-type': 'application/json' })
     .end(JSON.stringify({ error: 'Generation runs on the render host (Mac3). This deploy serves reels only.' })); return; }
   if (building) { res.writeHead(409).end(JSON.stringify({ error: 'a job is already running' })); return; }
   building = true; lastLog = '';
   const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}`, HYPERFRAMES_SKIP_SKILLS: '1' };
-  const ch = spawn(process.execPath, [join(ROOT, 'scripts', script)], { cwd: ROOT, env });
+  const ch = spawn(process.execPath, [join(ROOT, 'scripts', script), ...extraArgs], { cwd: ROOT, env });
   ch.stdout.on('data', d => { lastLog += d; });
   ch.stderr.on('data', d => { lastLog += d; });
   ch.on('close', code => { building = false; lastLog += `\n[exit ${code}]`; });
@@ -131,6 +131,15 @@ const server = http.createServer(async (req, res) => {
       .end(JSON.stringify({ building, log: lastLog.slice(-4000) }));
     if (p === '/api/refresh' && req.method === 'POST') return run('fetch-new-arrivals.mjs', res);
     if (p === '/api/build' && req.method === 'POST') return run('build-reel.mjs', res);
+    if (p === '/api/publish' && req.method === 'POST') {
+      // Publish a reel to Instagram via scripts/publish-ig.mjs. The real post
+      // only fires if this server process has SOCIAL_LIVE_ARMED=1 — otherwise
+      // publish-ig.mjs does a safe DRY run. Result lands in reels.json; the
+      // console re-reads it when the job finishes (same poll as build/refresh).
+      const { file } = await readBody(req);
+      if (!file || !/^[\w.\-]+\.mp4$/.test(file)) return res.writeHead(400).end(JSON.stringify({ error: 'bad file' }));
+      return run('publish-ig.mjs', res, ['--file', file]);
+    }
     if (p.startsWith('/reels/')) {
       const fp = normalize(join(ROOT, 'reels', p.slice('/reels/'.length)));
       if (fp.startsWith(join(ROOT, 'reels')) && existsSync(fp)) return await serveFile(fp, req, res);

← 2cd59c0 chore: v0.4.0 (session close) — FB-graph IG publish + publis  ·  back to Dw Marketing Reels  ·  chore: v0.4.1 55f1b05 →