← 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
M public/index.htmlM server.js
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 →