← back to Marketing Command Center
make social posting simple and credential safe
4c82b97206498e0608201ebb02cf767c979fe30a · 2026-08-28 08:19:14 -0700 · Steve Abrams
Files touched
M modules/channels/index.jsM modules/quickpost/index.jsM public/panels/quickpost.htmlM public/panels/quickpost.js
Diff
commit 4c82b97206498e0608201ebb02cf767c979fe30a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Aug 28 08:19:14 2026 -0700
make social posting simple and credential safe
---
modules/channels/index.js | 12 ++-
modules/quickpost/index.js | 4 +-
public/panels/quickpost.html | 87 ++++++++++-----------
public/panels/quickpost.js | 180 +++++++++++++++++++++++++------------------
4 files changed, 157 insertions(+), 126 deletions(-)
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 8b45fe0..c95f2ac 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -25,7 +25,11 @@ const GRAPH = 'https://graph.facebook.com/v23.0';
// sent to the browser (the /pages route sanitizes the tokens out).
const META_PAGES = path.join(__dirname, '..', '..', 'data', 'meta-pages.json');
const readMetaPages = () => { try { return JSON.parse(fs.readFileSync(META_PAGES, 'utf8')); } catch { return { fetchedAt: null, pages: [] }; } };
-const writeMetaPages = (o) => { fs.mkdirSync(path.dirname(META_PAGES), { recursive: true }); fs.writeFileSync(META_PAGES, JSON.stringify(o, null, 2)); };
+const writeMetaPages = (o) => {
+ fs.mkdirSync(path.dirname(META_PAGES), { recursive: true });
+ fs.writeFileSync(META_PAGES, JSON.stringify(o, null, 2), { mode: 0o600 });
+ fs.chmodSync(META_PAGES, 0o600); // existing files keep their old mode unless corrected
+};
async function fetchMetaPages() {
const token = env('META_ACCESS_TOKEN');
if (!token) throw new Error('META_ACCESS_TOKEN not set — paste it into .env first');
@@ -68,7 +72,11 @@ async function fetchMetaPages() {
// browser "Authorize" is all it takes once the app client id/secret are set.
const TOKENS = path.join(__dirname, '..', '..', 'data', 'channels-tokens.json');
const readTokens = () => { try { return JSON.parse(fs.readFileSync(TOKENS, 'utf8')); } catch { return {}; } };
-const writeTokens = (o) => { fs.mkdirSync(path.dirname(TOKENS), { recursive: true }); fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2)); };
+const writeTokens = (o) => {
+ fs.mkdirSync(path.dirname(TOKENS), { recursive: true });
+ fs.writeFileSync(TOKENS, JSON.stringify(o, null, 2), { mode: 0o600 });
+ fs.chmodSync(TOKENS, 0o600); // OAuth access/refresh tokens are owner-readable only
+};
// Public base for OAuth redirect URIs — register `${base}/api/channels/oauth/<platform>/callback`
// in each app console. Defaults to the canonical live host (matches .env's
// OAUTH_REDIRECT_BASE); override with OAUTH_REDIRECT_BASE.
diff --git a/modules/quickpost/index.js b/modules/quickpost/index.js
index dd24efb..be6a2ff 100644
--- a/modules/quickpost/index.js
+++ b/modules/quickpost/index.js
@@ -27,8 +27,8 @@ function newId() { return 'qp_' + Date.now().toString(36) + Math.random().toStri
module.exports = {
id: 'quickpost',
- title: 'Quick Post',
- icon: '🚀',
+ title: 'Make a Post',
+ icon: '✨',
mount(router) {
// per-platform connection state (best-effort). Tokens aren't wired yet, so
// everything reports draft-only; the front-end shows this honestly.
diff --git a/public/panels/quickpost.html b/public/panels/quickpost.html
index 831265e..e6f0455 100644
--- a/public/panels/quickpost.html
+++ b/public/panels/quickpost.html
@@ -1,50 +1,43 @@
-<div class="muted-banner">🚀 <b>Easy post</b> — pick media, write a caption, then tap a platform to <b>stage a draft</b>. Draft-only & gated: nothing goes live without your approval.</div>
-
-<div class="qp-grid">
- <section class="card qp-compose">
- <label class="qp-lbl">Media URL <span class="qp-hint">(paste a link, or pick an asset below)</span></label>
- <input id="qp-media" type="url" placeholder="https://…/video.mp4 or image.jpg" autocomplete="off">
- <div class="qp-preview" id="qp-preview"></div>
- <div class="qp-lbl" style="margin-top:10px">Pick an asset</div>
- <div id="qp-assets" class="qp-assets"><div class="muted" style="font-size:12px">Loading assets…</div></div>
- <label class="qp-lbl" style="margin-top:12px">Account <span class="qp-hint">(which DW-owned IG account)</span></label>
- <select id="qp-account"><option value="">Loading accounts…</option></select>
- <label class="qp-lbl" style="margin-top:12px">Caption</label>
- <textarea id="qp-caption" placeholder="Write your caption… #designerwallcoverings"></textarea>
- <div class="qp-lbl" style="margin-top:12px">Post to</div>
- <div id="qp-buttons"></div>
- </section>
-
- <aside class="card qp-drafts">
- <div class="qp-dh"><h3>Staged drafts</h3><span id="qp-count" class="pill">0</span></div>
- <div class="qp-hint" style="margin-bottom:8px">Awaiting your approval — no draft posts itself.</div>
- <div id="qp-draftlist"><div class="muted" style="font-size:12px">None yet.</div></div>
- </aside>
+<div class="kidpost" aria-labelledby="kp-title">
+ <header class="kp-hero">
+ <div><span class="kp-kicker">MAKE ONE GREAT POST</span><h2 id="kp-title">What do you want to share?</h2><p>Choose one thing at a time. You can go back whenever you want.</p></div>
+ <div class="kp-score" aria-live="polite"><b id="kp-progress">1 of 5</b><span>steps done</span></div>
+ </header>
+ <div class="kp-steps" role="tablist" aria-label="Post creation steps">
+ <button class="kp-step on" data-go="1" role="tab"><span>1</span><b>Picture</b></button><button class="kp-step" data-go="2" role="tab"><span>2</span><b>Music</b></button><button class="kp-step" data-go="3" role="tab"><span>3</span><b>Words</b></button><button class="kp-step" data-go="4" role="tab"><span>4</span><b>Where</b></button><button class="kp-step" data-go="5" role="tab"><span>5</span><b>Check</b></button>
+ </div>
+ <main class="kp-card">
+ <section class="kp-pane on" data-step="1">
+ <div class="kp-number">1</div><div class="kp-copy"><h3>Pick a picture or video</h3><p>Tap one you like. Every DW post needs a picture or video.</p></div>
+ <div id="kp-assets" class="kp-assets" aria-live="polite"><div class="kp-wait">Loading your pictures…</div></div>
+ <details class="kp-more"><summary>Paste a picture link instead</summary><input id="kp-media" type="url" placeholder="https://…/picture.jpg" autocomplete="off"></details>
+ </section>
+ <section class="kp-pane" data-step="2" hidden>
+ <div class="kp-number">2</div><div class="kp-copy"><h3>Add music?</h3><p>Music is optional. Pick one safe source, or choose “No music.”</p></div><div id="kp-music" class="kp-choices"><div class="kp-wait">Loading safe music…</div></div>
+ </section>
+ <section class="kp-pane" data-step="3" hidden>
+ <div class="kp-number">3</div><div class="kp-copy"><h3>Choose the words</h3><p>Tell us the idea. We will suggest three captions you can use or change.</p></div>
+ <label class="kp-label" for="kp-topic">What is this post about?</label><div class="kp-prompt"><input id="kp-topic" type="text" placeholder="Example: a new blue grasscloth wallpaper"><button id="kp-suggest" type="button">✨ Give me ideas</button></div>
+ <div id="kp-suggestions" class="kp-suggestions" aria-live="polite"></div><label class="kp-label" for="kp-caption">Your final words</label><textarea id="kp-caption" placeholder="Pick an idea above, or write your own words here."></textarea><div id="kp-count" class="kp-count">0 / 2,200</div>
+ </section>
+ <section class="kp-pane" data-step="4" hidden>
+ <div class="kp-number">4</div><div class="kp-copy"><h3>Where should it go?</h3><p>Pick one, several, or tap “Choose all.” Gray choices are not connected yet.</p></div>
+ <div class="kp-allrow"><button id="kp-all" type="button">✓ Choose all that work</button><button id="kp-none" type="button">Clear</button></div><div id="kp-channels" class="kp-channels" aria-live="polite"><div class="kp-wait">Checking your social accounts…</div></div>
+ <label class="kp-label" for="kp-account">Instagram account</label><select id="kp-account"><option value="">Loading accounts…</option></select>
+ </section>
+ <section class="kp-pane" data-step="5" hidden>
+ <div class="kp-number">5</div><div class="kp-copy"><h3>Check everything</h3><p>Nothing posts until you check the box and press the green button.</p></div><div id="kp-review" class="kp-review"></div>
+ <label class="kp-confirm"><input id="kp-confirm" type="checkbox"><span>I checked the picture, words, music, and accounts. Post it now.</span></label><button id="kp-publish" class="kp-publish" type="button" disabled>Post now</button><button id="kp-draft" class="kp-draft" type="button">Save as drafts instead</button><div id="kp-result" class="kp-result" role="status" aria-live="polite"></div>
+ </section>
+ <footer class="kp-nav"><button id="kp-back" type="button" disabled>← Back</button><button id="kp-next" type="button">Next: Music →</button></footer>
+ </main>
</div>
-
<style>
- .qp-grid{display:grid;grid-template-columns:1fr 320px;gap:16px;align-items:start}
- @media(max-width:900px){.qp-grid{grid-template-columns:1fr}}
- .qp-compose,.qp-drafts{padding:14px}
- .qp-lbl{display:block;font-size:12px;font-weight:600;color:var(--ink);margin-bottom:5px}
- #qp-media{width:100%;box-sizing:border-box;margin-bottom:8px}
- #qp-account{width:100%;box-sizing:border-box;font:inherit;font-size:13.5px;padding:9px 10px;
- border:1px solid var(--line);border-radius:10px;background:#fff;color:var(--ink)}
- #qp-caption{width:100%;box-sizing:border-box;min-height:100px;font:inherit;font-size:13.5px;line-height:1.5;
- padding:10px;border:1px solid var(--line);border-radius:10px;resize:vertical}
- .qp-preview{min-height:0}
- .qp-preview img,.qp-preview video{max-width:220px;max-height:220px;border-radius:10px;border:1px solid var(--line);display:block}
- .qp-assets{display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:6px;max-height:190px;overflow-y:auto}
- .qp-thumb{aspect-ratio:1/1;border:1px solid var(--line);border-radius:8px;overflow:hidden;cursor:pointer;background:#efeae1}
- .qp-thumb.sel{outline:3px solid var(--accent);outline-offset:-1px}
- .qp-thumb img{width:100%;height:100%;object-fit:cover;display:block}
- .qp-dh{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
- .qp-dh h3{margin:0;font:600 16px/1 "Cormorant Garamond",Georgia,serif}
- .qp-draftitem{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;margin-bottom:6px}
- .qp-draftitem .pl{font-size:16px}
- .qp-draftitem .mid{flex:1;min-width:0}
- .qp-draftitem .cap{font-size:11.5px;color:#5a544b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
- .qp-draftitem .when{font-size:10px;color:var(--mut)}
- .qp-draftitem .x{border:0;background:transparent;cursor:pointer;color:var(--mut);font:700 14px/1 Inter}
- .qp-draftitem .x:hover{color:var(--accent)}
+.kidpost{--navy:#172235;--paper:#fffdf7;--sun:#f6c84c;--green:#287a55;--coral:#df684f;max-width:1120px;margin:0 auto;color:var(--navy);font-family:"Avenir Next",Avenir,"Trebuchet MS",sans-serif}.kp-hero{display:flex;justify-content:space-between;align-items:flex-end;gap:24px;padding:24px 28px;background:var(--navy);color:#fff;border-radius:24px 24px 0 0;box-shadow:0 18px 45px rgba(23,34,53,.14)}.kp-kicker{font-size:12px;font-weight:800;letter-spacing:.16em;color:var(--sun)}.kp-hero h2{font:700 clamp(28px,4vw,48px)/1.02 "Cormorant Garamond",Georgia,serif;margin:7px 0}.kp-hero p{margin:0;color:#dce4ef;font-size:16px}.kp-score{background:var(--sun);color:var(--navy);border-radius:18px;padding:12px 17px;text-align:center;min-width:94px}.kp-score b{display:block;font-size:20px}.kp-score span{font-size:11px;font-weight:700}
+.kp-steps{display:grid;grid-template-columns:repeat(5,1fr);background:#edf0f3;padding:12px;gap:8px}.kp-step{border:0;background:transparent;padding:9px 4px;border-radius:14px;color:#6c7480;cursor:pointer}.kp-step span{display:grid;place-items:center;width:34px;height:34px;margin:0 auto 5px;border:2px solid #aab1ba;border-radius:50%;font-weight:800}.kp-step b{font-size:12px}.kp-step.on{background:#fff;color:var(--navy);box-shadow:0 4px 14px rgba(23,34,53,.09)}.kp-step.on span,.kp-step.done span{border-color:var(--green);background:var(--green);color:#fff}.kp-card{background:var(--paper);border:1px solid #e0ddd2;border-top:0;border-radius:0 0 24px 24px;box-shadow:0 18px 45px rgba(23,34,53,.14);overflow:hidden}.kp-pane{padding:30px;min-height:390px}.kp-number{float:left;display:grid;place-items:center;width:48px;height:48px;border-radius:16px;background:var(--sun);font-size:24px;font-weight:900;margin-right:14px}.kp-copy{min-height:58px}.kp-copy h3{font:700 28px/1.1 "Cormorant Garamond",Georgia,serif;margin:0 0 5px}.kp-copy p{margin:0;color:#667080;font-size:15px}
+.kp-assets{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:12px;margin-top:22px;max-height:360px;overflow:auto;padding:4px}.kp-asset{position:relative;aspect-ratio:1;border:3px solid transparent;border-radius:16px;overflow:hidden;background:#ece8de;cursor:pointer;padding:0}.kp-asset img{width:100%;height:100%;object-fit:cover}.kp-asset.on{border-color:var(--green);box-shadow:0 0 0 3px #c8ead9}.kp-asset.on:after{content:'✓';position:absolute;right:7px;top:7px;display:grid;place-items:center;width:28px;height:28px;border-radius:50%;background:var(--green);color:#fff;font-weight:900}.kp-more{margin-top:18px;color:#5c6570}.kp-more summary{cursor:pointer;font-weight:700}.kp-more input,.kp-prompt input,#kp-caption,#kp-account{width:100%;box-sizing:border-box;margin-top:9px;border:2px solid #d7d9dc;border-radius:13px;padding:12px 14px;font:inherit;background:#fff}.kp-more input:focus,.kp-prompt input:focus,#kp-caption:focus,#kp-account:focus{outline:3px solid #f8dfa0;border-color:#c99927}
+.kp-choices,.kp-channels{display:grid;grid-template-columns:repeat(auto-fill,minmax(205px,1fr));gap:12px;margin-top:22px}.kp-choice,.kp-channel{min-height:86px;text-align:left;border:2px solid #dadddf;background:#fff;border-radius:16px;padding:14px;cursor:pointer;color:var(--navy);font:inherit}.kp-choice b,.kp-channel b{display:block;font-size:15px;margin-bottom:4px}.kp-choice small,.kp-channel small{display:block;color:#6b7480;line-height:1.35}.kp-choice.on,.kp-channel.on{border-color:var(--green);background:#ecf8f1;box-shadow:0 0 0 2px #c8ead9}.kp-channel.off{opacity:.48;cursor:not-allowed}.kp-label{display:block;margin-top:20px;font-weight:800;font-size:14px}.kp-prompt{display:flex;gap:10px}.kp-prompt input{margin:9px 0 0}.kp-prompt button,#kp-all,#kp-none{border:0;border-radius:12px;padding:12px 16px;font-weight:800;cursor:pointer;white-space:nowrap}.kp-prompt button{margin-top:9px;background:var(--sun);color:var(--navy)}
+.kp-suggestions{display:grid;gap:9px;margin-top:14px}.kp-suggestion{border:2px solid #dadddf;background:#fff;border-radius:14px;padding:12px 14px;text-align:left;cursor:pointer;font:inherit;line-height:1.45}.kp-suggestion:hover{border-color:#c99927;background:#fffaec}.kp-suggestion:before{content:'Use this';display:block;color:var(--green);font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}#kp-caption{min-height:130px;resize:vertical}.kp-count{text-align:right;color:#707987;font-size:12px;margin-top:4px}.kp-allrow{display:flex;gap:9px;margin-top:20px}#kp-all{background:var(--navy);color:#fff}#kp-none{background:#e9ecef;color:var(--navy)}
+.kp-review{display:grid;grid-template-columns:190px 1fr;gap:20px;margin-top:22px;border:2px solid #dadddf;border-radius:18px;padding:16px;background:#fff}.kp-preview{aspect-ratio:1;border-radius:13px;overflow:hidden;background:#ece8de}.kp-preview img,.kp-preview video{width:100%;height:100%;object-fit:cover}.kp-summary h4{margin:2px 0 5px;font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:#75808c}.kp-summary p{white-space:pre-wrap;line-height:1.5;margin:0 0 14px}.kp-tags{display:flex;gap:7px;flex-wrap:wrap}.kp-tag{background:#edf1f4;border-radius:999px;padding:6px 10px;font-size:12px;font-weight:700}.kp-confirm{display:flex;gap:12px;align-items:flex-start;background:#fff5d5;border:2px solid #efd06f;border-radius:15px;padding:14px;margin-top:17px;font-weight:700;line-height:1.4}.kp-confirm input{width:22px;height:22px;flex:none}.kp-publish,.kp-draft{width:100%;border:0;border-radius:14px;padding:15px;margin-top:12px;font:800 17px/1 inherit;cursor:pointer}.kp-publish{background:var(--green);color:#fff}.kp-publish:disabled{background:#aeb6b1;cursor:not-allowed}.kp-draft{background:#e9ecef;color:var(--navy)}.kp-result{margin-top:12px;font-weight:700;line-height:1.45}.kp-result.ok{color:var(--green)}.kp-result.bad{color:#a33c2f}.kp-nav{display:flex;justify-content:space-between;border-top:1px solid #e3e1d9;padding:16px 30px;background:#fff}.kp-nav button{border:0;border-radius:12px;padding:12px 18px;font-weight:800;cursor:pointer}.kp-nav button:disabled{opacity:.35}.kp-nav #kp-next{background:var(--coral);color:#fff}.kp-wait{grid-column:1/-1;padding:28px;text-align:center;color:#68717c}.kp-error{background:#fff0eb;color:#923f32;border-radius:12px;padding:12px;margin-top:12px}
+@media(max-width:700px){.kp-hero{align-items:flex-start;padding:20px}.kp-score{display:none}.kp-steps{gap:3px}.kp-step b{display:none}.kp-pane{padding:22px 16px}.kp-assets{grid-template-columns:repeat(3,1fr)}.kp-prompt{display:block}.kp-prompt button{width:100%}.kp-review{grid-template-columns:1fr}.kp-preview{max-height:280px}.kp-nav{padding:14px 16px}}
</style>
diff --git a/public/panels/quickpost.js b/public/panels/quickpost.js
index 06799f3..f001293 100644
--- a/public/panels/quickpost.js
+++ b/public/panels/quickpost.js
@@ -1,87 +1,117 @@
-// Quick Post panel — pick media + caption, stage a draft to any platform via the
-// shared MCCQuickPost component (loaded globally in index.html). Draft-only/gated.
+// Kid-simple social post wizard. Combines existing asset, sounds, copy, account,
+// and channel APIs into five small choices with one explicit live-post gate.
window.MCC_PANELS = window.MCC_PANELS || {};
-window.MCC_PANELS['quickpost'] = {
- init(root) {
+window.MCC_PANELS.quickpost = {
+ async init(root) {
const O = location.origin;
const $ = s => root.querySelector(s);
- const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
- ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
- const media = $('#qp-media'), cap = $('#qp-caption'), preview = $('#qp-preview'), account = $('#qp-account');
- const PICON = { instagram: '📷', tiktok: '🎵', facebook: '📘', linkedin: '💼' };
+ const $$ = s => [...root.querySelectorAll(s)];
+ const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ const icons = {facebook:'📘',instagram:'📸',bluesky:'🦋',linkedin:'💼',tiktok:'🎵',youtube:'▶️',threads:'🧵'};
+ const state = { step:1, mediaUrl:'', song:null, channels:[], targets:[], status:{} };
+ const names = ['','Picture','Music','Words','Where','Check'];
- // populate the DW-owned IG account selector (all 35) from the canonical source
- (async function loadAccounts() {
- let accts = [];
- try { accts = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; } catch (_) {}
- if (!accts.length) { account.innerHTML = '<option value="">No accounts found</option>'; return; }
- account.innerHTML = accts.map(a =>
- `<option value="${esc(a.handle)}">@${esc(a.handle)}${a.name ? ' — ' + esc(a.name) : ''}</option>`).join('');
- })();
+ function note(step, msg) {
+ const p = root.querySelector(`[data-step="${step}"]`);
+ let e = p.querySelector('.kp-error');
+ if (!e) { e = document.createElement('div'); e.className = 'kp-error'; p.appendChild(e); }
+ e.textContent = msg;
+ }
+ function showStep(n) {
+ state.step = Math.max(1, Math.min(5, n));
+ $$('.kp-pane').forEach(p => { const on = +p.dataset.step === state.step; p.hidden = !on; p.classList.toggle('on', on); });
+ $$('.kp-step').forEach(b => { const x = +b.dataset.go; b.classList.toggle('on', x === state.step); b.classList.toggle('done', x < state.step); b.setAttribute('aria-selected', String(x === state.step)); });
+ $('#kp-progress').textContent = state.step + ' of 5';
+ $('#kp-back').disabled = state.step === 1;
+ $('#kp-next').hidden = state.step === 5;
+ if (state.step < 5) $('#kp-next').textContent = 'Next: ' + names[state.step + 1] + ' →';
+ if (state.step === 5) renderReview();
+ root.scrollIntoView({behavior:'smooth', block:'start'});
+ }
+ $$('.kp-step').forEach(b => b.onclick = () => showStep(+b.dataset.go));
+ $('#kp-back').onclick = () => showStep(state.step - 1);
+ $('#kp-next').onclick = () => {
+ if (state.step === 1 && !state.mediaUrl) return note(1, 'Pick a picture or paste a picture link first.');
+ if (state.step === 3 && !$('#kp-caption').value.trim()) return note(3, 'Pick suggested words or write your own first.');
+ if (state.step === 4 && !state.channels.length) return note(4, 'Pick at least one social account first.');
+ showStep(state.step + 1);
+ };
- function renderPreview() {
- const u = media.value.trim();
- if (!u) { preview.innerHTML = ''; return; }
- preview.innerHTML = /\.(mp4|mov|webm|m4v)(\?|$)/i.test(u)
- ? `<video src="${esc(u)}" muted playsinline preload="metadata"></video>`
- : `<img src="${esc(u)}" alt="" onerror="this.style.display='none'">`;
+ async function loadAssets() {
+ try {
+ const d = await fetch(O + '/api/assets/list', {credentials:'same-origin'}).then(r => r.json());
+ const assets = (d.assets || []).filter(a => a.src).slice(0, 48);
+ $('#kp-assets').innerHTML = assets.length ? assets.map((a,i) => `<button class="kp-asset" type="button" data-url="${esc(new URL(a.src, O).href)}" aria-label="Choose ${esc(a.name || 'picture '+(i+1))}"><img loading="lazy" src="${esc(a.src)}" alt="${esc(a.name || 'Wallpaper picture')}"></button>`).join('') : '<div class="kp-wait">No saved pictures yet. Paste a picture link below.</div>';
+ $$('.kp-asset').forEach(b => b.onclick = () => { $$('.kp-asset').forEach(x => x.classList.remove('on')); b.classList.add('on'); state.mediaUrl = b.dataset.url; $('#kp-media').value = state.mediaUrl; });
+ } catch (_) { $('#kp-assets').innerHTML = '<div class="kp-error">Pictures could not load. Paste a picture link below.</div>'; }
}
- media.addEventListener('input', renderPreview);
+ $('#kp-media').addEventListener('input', e => { state.mediaUrl = e.target.value.trim(); $$('.kp-asset').forEach(x => x.classList.toggle('on', x.dataset.url === state.mediaUrl)); });
- // wire the shared button row — payload is read live at click time
- if (window.MCCQuickPost) {
- window.MCCQuickPost.attach($('#qp-buttons'),
- () => ({ caption: cap.value.trim(), mediaUrl: media.value.trim(), handle: account.value || '', source: 'quickpost-panel' }),
- { live: true });
- } else {
- $('#qp-buttons').innerHTML = '<div class="muted" style="font-size:12px">Quick-post component not loaded.</div>';
+ async function loadMusic() {
+ try {
+ const d = await fetch(O + '/api/sounds/catalog', {credentials:'same-origin'}).then(r => r.json());
+ const items = (d.catalog || []).flatMap(s => s.items || []).filter(x => x.badge === 'brandsafe').slice(0, 8);
+ $('#kp-music').innerHTML = `<button class="kp-choice on" type="button" data-music=""><b>🔇 No music</b><small>That is completely okay.</small></button>` + items.map(x => `<button class="kp-choice" type="button" data-music="${esc(x.url)}" data-name="${esc(x.name)}"><b>🎵 ${esc(x.name)}</b><small>${esc((x.note || 'Brand-safe music').slice(0,100))}</small></button>`).join('');
+ $$('.kp-choice').forEach(b => b.onclick = () => { $$('.kp-choice').forEach(x => x.classList.remove('on')); b.classList.add('on'); state.song = b.dataset.music ? {name:b.dataset.name,url:b.dataset.music} : null; });
+ } catch (_) { $('#kp-music').innerHTML = '<button class="kp-choice on" type="button"><b>🔇 No music</b><small>Music choices could not load.</small></button>'; }
}
- // asset quick-pick
- (async function loadAssets() {
- const box = $('#qp-assets');
- let items = [];
- try { const d = await fetch(O + '/api/assets/list', { credentials: 'same-origin' }).then(r => r.json()); items = (d && d.assets) || []; }
- catch (_) {}
- const withImg = items.filter(a => a.src).slice(0, 60);
- if (!withImg.length) { box.innerHTML = '<div class="muted" style="font-size:12px">No assets yet — add some in the Library.</div>'; return; }
- box.innerHTML = withImg.map(a =>
- `<div class="qp-thumb" data-url="${esc(location.origin + a.src)}" title="${esc(a.name || '')}"><img loading="lazy" src="${esc(a.src)}"></div>`).join('');
- box.querySelectorAll('.qp-thumb').forEach(el => el.onclick = () => {
- box.querySelectorAll('.qp-thumb').forEach(x => x.classList.remove('sel'));
- el.classList.add('sel');
- media.value = el.getAttribute('data-url'); renderPreview();
- });
- })();
+ function count() { const c = $('#kp-caption'); if (c.value.length > 2200) c.value = c.value.slice(0,2200); $('#kp-count').textContent = c.value.length.toLocaleString() + ' / 2,200'; }
+ $('#kp-caption').addEventListener('input', count);
+ $('#kp-suggest').onclick = async () => {
+ const topic = $('#kp-topic').value.trim();
+ if (!topic) return note(3, 'First, type what the post is about.');
+ const btn = $('#kp-suggest'); btn.disabled = true; btn.textContent = 'Writing ideas…'; $('#kp-suggestions').innerHTML = '<div class="kp-wait">Thinking of three friendly captions…</div>';
+ try {
+ const d = await fetch(O + '/api/copy/generate', {method:'POST', credentials:'same-origin', headers:{'content-type':'application/json'}, body:JSON.stringify({kind:'social',topic,product:'wallcovering',audience:'designers and homeowners',tone:'warm',n:3})}).then(r => r.json());
+ const variants = (d.variants || []).map(v => typeof v === 'string' ? v : v.text).filter(Boolean);
+ $('#kp-suggestions').innerHTML = variants.map(v => `<button class="kp-suggestion" type="button" data-text="${esc(v)}">${esc(v)}</button>`).join('') || '<div class="kp-error">No ideas came back. Try a few different words.</div>';
+ $$('.kp-suggestion').forEach(b => b.onclick = () => { $('#kp-caption').value = b.dataset.text; count(); });
+ } catch (_) { $('#kp-suggestions').innerHTML = '<div class="kp-error">Ideas could not load. You can still write your own.</div>'; }
+ finally { btn.disabled = false; btn.textContent = '✨ Give me ideas'; }
+ };
- // staged-drafts list
- async function loadDrafts() {
- const box = $('#qp-draftlist'), cnt = $('#qp-count');
- let drafts = [];
- try { const d = await fetch(O + '/api/quickpost/drafts', { credentials: 'same-origin' }).then(r => r.json()); drafts = (d && d.drafts) || []; }
- catch (_) {}
- if (cnt) cnt.textContent = drafts.length;
- if (!drafts.length) { box.innerHTML = '<div class="muted" style="font-size:12px">None yet.</div>'; return; }
- box.innerHTML = drafts.map(d => {
- const when = new Date(d.created_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
- return `<div class="qp-draftitem">
- <span class="pl">${PICON[d.platform] || '•'}</span>
- <div class="mid">
- <div class="cap" title="${esc(d.caption)}">${esc(d.caption || '(no caption)')}</div>
- <div class="when">🕓 ${esc(when)} · ${esc(d.platform)}${d.mediaUrl ? ' · has media' : ''}</div>
- </div>
- <button class="x" data-del="${esc(d.id)}" title="Remove draft">✕</button>
- </div>`;
- }).join('');
- box.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => {
- await fetch(O + '/api/quickpost/draft/delete', {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: b.getAttribute('data-del') }),
- }).catch(() => {});
- loadDrafts();
- });
+ async function loadChannels() {
+ try {
+ const [t,s] = await Promise.all([fetch(O + '/api/composer/targets',{credentials:'same-origin'}).then(r=>r.json()), fetch(O + '/api/channels/status',{credentials:'same-origin'}).then(r=>r.json())]);
+ state.targets = t.targets || []; state.status = s.platforms || s.status || s || {};
+ $('#kp-channels').innerHTML = state.targets.map(x => { const st=state.status[x.id]||{}; const works=!!st.connected && st.postable!==false; const why=works?'Ready to post':(st.postableNote||'Not connected yet'); return `<button class="kp-channel${works?'':' off'}" type="button" data-channel="${esc(x.id)}" ${works?'':'disabled'}><b>${icons[x.id]||x.icon||'●'} ${esc(x.label)}</b><small>${esc(why)}</small></button>`; }).join('');
+ $$('.kp-channel:not(.off)').forEach(b => b.onclick = () => { b.classList.toggle('on'); state.channels = $$('.kp-channel.on').map(x=>x.dataset.channel); });
+ } catch (_) { $('#kp-channels').innerHTML = '<div class="kp-error">Social accounts could not be checked.</div>'; }
+ }
+ $('#kp-all').onclick = () => { $$('.kp-channel:not(.off)').forEach(b=>b.classList.add('on')); state.channels = $$('.kp-channel.on').map(x=>x.dataset.channel); };
+ $('#kp-none').onclick = () => { $$('.kp-channel').forEach(b=>b.classList.remove('on')); state.channels=[]; };
+ async function loadAccounts() { let a=[]; try { a=(window.MCC_ACCOUNTS&&await window.MCC_ACCOUNTS.load())||[]; } catch (_) {} $('#kp-account').innerHTML='<option value="">Main Designer Wallcoverings account</option>'+a.map(x=>`<option value="${esc(x.handle)}">@${esc(x.handle)}${x.name?' — '+esc(x.name):''}</option>`).join(''); }
+
+ function renderReview() {
+ const cap=$('#kp-caption').value.trim(); const isVideo=/\.(mp4|mov|webm|m4v)(\?|$)/i.test(state.mediaUrl);
+ const media=state.mediaUrl ? (isVideo?`<video src="${esc(state.mediaUrl)}" controls playsinline></video>`:`<img src="${esc(state.mediaUrl)}" alt="Chosen post picture">`) : '<div class="kp-wait">No picture</div>';
+ const tags=state.channels.map(c=>`<span class="kp-tag">${icons[c]||'●'} ${esc(c)}</span>`).join('');
+ $('#kp-review').innerHTML=`<div class="kp-preview">${media}</div><div class="kp-summary"><h4>Your words</h4><p>${esc(cap||'No words')}</p><h4>Music</h4><p>${esc(state.song?state.song.name:'No music')}</p><h4>Posting to</h4><div class="kp-tags">${tags||'<span class="kp-tag">None chosen</span>'}</div></div>`;
+ $('#kp-confirm').checked=false; $('#kp-publish').disabled=true; $('#kp-result').textContent='';
+ }
+ $('#kp-confirm').onchange=e => { $('#kp-publish').disabled=!e.target.checked; };
+ async function submit(live) {
+ const result=$('#kp-result'); const btn=live?$('#kp-publish'):$('#kp-draft');
+ if(!state.mediaUrl||!state.channels.length||!$('#kp-caption').value.trim()){result.className='kp-result bad';result.textContent='Go back and choose a picture, words, and at least one account.';return;}
+ if(live&&!$('#kp-confirm').checked)return;
+ btn.disabled=true; result.className='kp-result'; result.textContent=live?'Posting… Please keep this page open.':'Saving drafts…';
+ try {
+ const isVideo=/\.(mp4|mov|webm|m4v)(\?|$)/i.test(state.mediaUrl);
+ const d=await fetch(O+'/api/channels/publish',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({channels:state.channels,handles:$('#kp-account').value?[$('#kp-account').value]:[],caption:$('#kp-caption').value.trim(),mediaUrl:state.mediaUrl,videoUrl:isVideo?state.mediaUrl:'',song:state.song,confirm:live,dryRun:!live})}).then(async r=>{const j=await r.json();if(!r.ok)throw new Error(j.error||'Could not post');return j;});
+ const posted=(d.results||[]).filter(x=>x.live&&x.ok).length, staged=(d.results||[]).filter(x=>!x.live).length;
+ result.className = 'kp-result ok';
+ if (live) {
+ const saved = staged ? `; ${staged} unavailable account${staged === 1 ? ' was' : 's were'} saved instead` : '';
+ result.textContent = `Done. ${posted} account${posted === 1 ? '' : 's'} posted${saved}.`;
+ } else {
+ result.textContent = `Saved ${staged} draft${staged === 1 ? '' : 's'}. Nothing was posted.`;
+ }
+ } catch(e) { result.className='kp-result bad'; result.textContent='Not posted: '+e.message; }
+ finally { btn.disabled=false; if(live){$('#kp-confirm').checked=false;$('#kp-publish').disabled=true;} }
}
- document.addEventListener('qp:staged', loadDrafts);
- loadDrafts();
- },
+ $('#kp-publish').onclick=()=>submit(true); $('#kp-draft').onclick=()=>submit(false);
+ await Promise.all([loadAssets(),loadMusic(),loadChannels(),loadAccounts()]);
+ showStep(1);
+ }
};
← 2fd6afa snapshot before kid-friendly post creator
·
back to Marketing Command Center
·
snapshot before MCC click-through debug a34711a →