← back to Dw Launches
dw-launches: remove sign-off gate, single Publish Now button; live dispatch path (honest no-creds state)
4b5d8af60174e5993a24129535396d7796784591 · 2026-06-20 11:24:57 -0700 · SteveStudio2
Files touched
M data/launches.jsonM public/index.htmlM server.js
Diff
commit 4b5d8af60174e5993a24129535396d7796784591
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Sat Jun 20 11:24:57 2026 -0700
dw-launches: remove sign-off gate, single Publish Now button; live dispatch path (honest no-creds state)
---
data/launches.json | 32 ++++++++++++++++++++++++++++++++
public/index.html | 44 ++++++++++++++++++++------------------------
server.js | 54 +++++++++++++++++++++++++++++++++++++++++-------------
3 files changed, 93 insertions(+), 37 deletions(-)
diff --git a/data/launches.json b/data/launches.json
index 386d94c..97c2ddf 100644
--- a/data/launches.json
+++ b/data/launches.json
@@ -234,6 +234,38 @@
"published": false,
"created_at": "2026-06-20T18:16:59.982Z",
"updated_at": "2026-06-20T18:17:10.243Z"
+ },
+ {
+ "id": "72149f1c-5251-4278-b001-fc7fe1ee3dbb",
+ "title": "Use — Evelyn - Tigers Eye/Bluestone wallcovering | Zoffa",
+ "channelsSelected": [
+ "instagram"
+ ],
+ "global": {
+ "body": "Evelyn - Tigers Eye/Bluestone wallcovering | Zoffany",
+ "media": [
+ {
+ "type": "image",
+ "ref": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/ZTOT312735_1_ZOW0061_03_ZOFFANY_Evelyn_WALLPAPER_e4a7.jpg?v=1773021666",
+ "alt": "Evelyn - Tigers Eye/Bluestone wallcovering | Zoffany",
+ "name": "Evelyn - Tigers Eye/Bluestone wallcoveri"
+ }
+ ]
+ },
+ "channels": {},
+ "schedule": {
+ "mode": "draft",
+ "datetime": ""
+ },
+ "status": "approved",
+ "approval": {
+ "approved": true,
+ "by": "Steve",
+ "at": "2026-06-20T18:20:15.334Z"
+ },
+ "published": false,
+ "created_at": "2026-06-20T18:20:08.176Z",
+ "updated_at": "2026-06-20T18:20:15.334Z"
}
]
}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index 35bc6d9..779354d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1053,18 +1053,14 @@
<span class="sl-title">State & Approval</span>
</div>
<div id="state-line"><span class="badge b-draft">draft</span></div>
- <div class="gatebar warn" id="gatebar">
- <strong>Publish is gated.</strong>
- <div class="muted">Nothing goes live without explicit Steve sign-off. The publish button stays disabled until approval. This tool never calls a live social API.</div>
+ <div class="gatebar" id="gatebar">
+ <strong>Ready to publish.</strong>
+ <div class="muted">No sign-off required — press Publish Now to go live.</div>
</div>
<div class="approval-actions">
<div class="row">
<button class="btn ghost" onclick="saveLaunch('draft')">Save draft</button>
- <button class="btn btn-submit" onclick="submitApproval()">Submit for approval</button>
- </div>
- <div class="row">
- <button class="btn btn-approve" id="btn-approve" onclick="approve()">Steve sign-off ✓</button>
- <button class="btn btn-publish" id="btn-publish" onclick="publish()" disabled>Publish (gated)</button>
+ <button class="btn btn-publish" id="btn-publish" onclick="publish()">⚡ Publish Now</button>
</div>
</div>
</div>
@@ -1524,10 +1520,11 @@ function renderState(){
document.getElementById('state-line').innerHTML=`<span class="badge ${m[state.status]||'b-draft'}">${(state.status||'draft').replace('_',' ')}</span>`
+ (state.approval&&state.approval.approved?` <span class="muted">· signed off by ${state.approval.by} ${new Date(state.approval.at).toLocaleString()}</span>`:'');
const gate=document.getElementById('gatebar');
- const approved=state.approval&&state.approval.approved;
- document.getElementById('btn-publish').disabled=!approved;
- gate.className='gatebar'+(approved?'':' warn');
- gate.querySelector('strong').textContent = approved?'Approved — publish unlocked (simulated only).':'Publish is gated.';
+ const pub=state.published||state.status==='published';
+ const btn=document.getElementById('btn-publish');
+ if(btn){ btn.disabled=pub; btn.textContent=pub?'✓ Published':'⚡ Publish Now'; }
+ gate.className='gatebar'+(pub?'':' ');
+ gate.querySelector('strong').textContent = pub?'Published — live.':'Ready to publish.';
}
// ---------- actions ----------
@@ -1559,10 +1556,10 @@ async function approve(){
catch(e){ toast('Sign-off rejected: '+e.message,true); }
}
async function publish(){
- if(!state.id) return;
+ if(!state.id){ await saveLaunch('draft'); if(!state.id) return; }
try{ const j=await api(`/api/launches/${state.id}/publish`,{method:'POST'}); state=j.launch; renderState();
- toast('SIMULATED publish — no live API called. '+(j.note||'')); }
- catch(e){ toast('Publish blocked: '+e.message,true); }
+ toast(j.note||'Published.', j.live===false); }
+ catch(e){ toast('Publish failed: '+e.message,true); }
}
// ---------- views ----------
@@ -2067,8 +2064,8 @@ async function renderApprovals(){
</div>
<div class="ac-actions">
<button class="btn ghost" data-act="open" data-id="${esc(l.id)}">Open</button>
- ${pending?`<button class="btn btn-approve" data-act="approve" data-id="${esc(l.id)}">Steve sign-off ✓</button>`
- :`<button class="btn btn-publish" data-act="publish" data-id="${esc(l.id)}">Publish (simulated)</button>`}
+ ${(l.published||l.status==='published')?`<span class="muted" style="color:#5ab880">✓ Published</span>`
+ :`<button class="btn btn-publish" data-act="publish" data-id="${esc(l.id)}">⚡ Publish Now</button>`}
</div>`;
el.appendChild(d);
});
@@ -2096,8 +2093,8 @@ async function publishById(id){
try{
const j=await api(`/api/launches/${id}/publish`,{method:'POST'});
if(state&&state.id===id) state=j.launch;
- toast('SIMULATED publish — no live API called. '+(j.note||'')); return true;
- }catch(e){ toast('Publish blocked: '+e.message,true); return false; }
+ toast(j.note||'Published.', j.live===false); return true;
+ }catch(e){ toast('Publish failed: '+e.message,true); return false; }
}
// ---------- launch as movable modal (modal-rig) ----------
@@ -2132,11 +2129,10 @@ async function openLaunchModal(id){
<div id="ml-sec-body">${chanRows}</div>
<div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
<button class="btn ghost" id="ml-edit">Load into Compose</button>
- ${l.status==='draft'?`<button class="btn btn-submit" id="ml-submit">Submit for approval</button>`:''}
- ${(l.status==='pending_approval')?`<button class="btn btn-approve" id="ml-approve">Steve sign-off ✓</button>`:''}
- <button class="btn btn-publish" id="ml-publish" ${approved?'':'disabled'}>${approved?'Publish (simulated)':'Publish (gated)'}</button>
- </div>
- ${approved?'':'<div class="gatebar warn" style="margin-top:10px"><strong>Publish is gated.</strong><div class="muted">Requires explicit Steve sign-off (STEVE-GO). Simulated only — no live API.</div></div>'}`;
+ ${(l.published||l.status==='published')
+ ?`<span class="muted" style="align-self:center;color:#5ab880">✓ Published</span>`
+ :`<button class="btn btn-publish" id="ml-publish">⚡ Publish Now</button>`}
+ </div>`;
const m=ModalRig.open({ key:'launch-viewer', title:l.title||'Launch', bodyHtml, width:560, height:560, bg:'var(--panel)' });
const sh=m.body.querySelector('#ml-sec-head'), sb=m.body.querySelector('#ml-sec-body');
diff --git a/server.js b/server.js
index aa53364..83ef453 100644
--- a/server.js
+++ b/server.js
@@ -157,26 +157,54 @@ app.post('/api/launches/:id/approve', (req, res) => {
res.json({ launch: l });
});
-// Publish — GATED STUB. Refuses unless approval flag is set. Never calls a real API.
-app.post('/api/launches/:id/publish', (req, res) => {
+// Publish NOW — no sign-off gate. Dispatches live to any channel that has real
+// API credentials configured; otherwise records the publish locally and reports
+// live:false so the UI tells the truth (no live social channel is connected yet).
+app.post('/api/launches/:id/publish', async (req, res) => {
const db = loadStore();
const l = db.launches.find(x => x.id === req.params.id);
if (!l) return res.status(404).json({ error: 'not found' });
- if (!l.approval || l.approval.approved !== true) {
- return res.status(403).json({
- error: 'PUBLISH_GATED',
- message: 'Publish is blocked. Nothing goes live without explicit Steve sign-off (approval flag).'
- });
- }
- // STUB: would dispatch to per-channel APIs here. We DO NOT — local tool only.
+
+ const channels = (l.channelsSelected && l.channelsSelected.length)
+ ? l.channelsSelected : Object.keys(l.channels || {});
+ const { live, results, note } = await dispatchLive(l, channels);
+
l.status = 'published';
- l.published = true; // simulated only — no live network call was made
- l.publishedSimulatedAt = new Date().toISOString();
- l.updated_at = new Date().toISOString();
+ l.published = true;
+ l.live = live;
+ l.publishResults = results;
+ l.publishedAt = new Date().toISOString();
+ l.updated_at = l.publishedAt;
saveStore(db);
- res.json({ launch: l, note: 'SIMULATED publish only — no live social API was called. Local build tool.' });
+ res.json({ launch: l, live, results, note });
});
+// Live dispatch. Each channel posts ONLY if its real credentials are present in
+// the environment. None are wired today, so this returns live:false with an
+// honest note rather than pretending. Add per-channel posting here + creds to
+// go truly live (e.g. Instagram via Meta Graph Content Publishing).
+async function dispatchLive(launch, channels) {
+ const results = [];
+ let anyLive = false;
+ for (const ch of channels) {
+ const handler = LIVE_CHANNELS[ch];
+ if (handler && handler.ready()) {
+ try { const r = await handler.post(launch); results.push({ channel: ch, ok: true, ...r }); anyLive = true; }
+ catch (e) { results.push({ channel: ch, ok: false, error: String(e.message || e) }); }
+ } else {
+ results.push({ channel: ch, ok: false, skipped: true, reason: 'no live credentials configured' });
+ }
+ }
+ const note = anyLive
+ ? `Published live to ${results.filter(r => r.ok).map(r => r.channel).join(', ') || 'channels'}.`
+ : 'Published — recorded locally. No live social channel is connected yet (add channel API credentials to post to real accounts).';
+ return { live: anyLive, results, note };
+}
+
+// Per-channel live posters — empty until real credentials + posting code land.
+// Example shape: instagram: { ready: () => !!process.env.IG_ACCESS_TOKEN, post: async (l) => {...} }
+const LIVE_CHANNELS = {};
+
app.delete('/api/launches/:id', (req, res) => {
const db = loadStore();
const before = db.launches.length;
← 4782ead Media: content-hash (md5) dedup — collapse byte-identical co
·
back to Dw Launches
·
dw-launches: route Instagram Publish Now through Norma insta b821953 →