← back to Marketing Command Center
Embed all 35 DW-owned Instagram accounts across every social/account panel
093bb5705fb67a0fc22ab9b346bdcb94ad5e69d2 · 2026-08-25 09:30:19 -0700 · Steve
Add one canonical accounts source (data/dw-ig-accounts.json, built from Norma's
registry by scripts/build-dw-accounts.js) served at GET /api/dw-accounts and read
by a shared public/dw-accounts.js helper. Retrofit the 9 social/account panels
(ig-activity, accounts, channels, composer, social, follow-counts, quickpost,
vendors, board) to union the canonical 35 with each panel's dynamic data so every
owned account shows everywhere — not just the ones with posts/data on a surface.
No new nav panel (endpoint mounts directly like clients-notes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A data/dw-ig-accounts.jsonM package.jsonA public/dw-accounts.jsM public/index.htmlM public/panels/accounts.htmlM public/panels/accounts.jsM public/panels/board.htmlM public/panels/board.jsM public/panels/channels.jsM public/panels/composer.htmlM public/panels/composer.jsM public/panels/follow-counts.jsM public/panels/ig-activity.jsM public/panels/quickpost.htmlM public/panels/quickpost.jsM public/panels/social.htmlM public/panels/social.jsM public/panels/vendors.htmlM public/panels/vendors.jsA scripts/build-dw-accounts.jsM server.js
Diff
commit 093bb5705fb67a0fc22ab9b346bdcb94ad5e69d2
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 25 09:30:19 2026 -0700
Embed all 35 DW-owned Instagram accounts across every social/account panel
Add one canonical accounts source (data/dw-ig-accounts.json, built from Norma's
registry by scripts/build-dw-accounts.js) served at GET /api/dw-accounts and read
by a shared public/dw-accounts.js helper. Retrofit the 9 social/account panels
(ig-activity, accounts, channels, composer, social, follow-counts, quickpost,
vendors, board) to union the canonical 35 with each panel's dynamic data so every
owned account shows everywhere — not just the ones with posts/data on a surface.
No new nav panel (endpoint mounts directly like clients-notes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
data/dw-ig-accounts.json | 182 +++++++++++++++++++++++++++++++++++++++++
package.json | 3 +-
public/dw-accounts.js | 50 +++++++++++
public/index.html | 1 +
public/panels/accounts.html | 2 +
public/panels/accounts.js | 55 ++++++++++++-
public/panels/board.html | 5 ++
public/panels/board.js | 56 ++++++++++++-
public/panels/channels.js | 25 +++++-
public/panels/composer.html | 21 +++++
public/panels/composer.js | 58 ++++++++++++-
public/panels/follow-counts.js | 55 ++++++++-----
public/panels/ig-activity.js | 12 ++-
public/panels/quickpost.html | 4 +
public/panels/quickpost.js | 13 ++-
public/panels/social.html | 8 ++
public/panels/social.js | 29 +++++++
public/panels/vendors.html | 9 +-
public/panels/vendors.js | 22 +++++
scripts/build-dw-accounts.js | 64 +++++++++++++++
server.js | 15 ++++
21 files changed, 648 insertions(+), 41 deletions(-)
diff --git a/data/dw-ig-accounts.json b/data/dw-ig-accounts.json
new file mode 100644
index 0000000..e3f8b94
--- /dev/null
+++ b/data/dw-ig-accounts.json
@@ -0,0 +1,182 @@
+{
+ "source": "Norma/agents/instagram-agent/accounts.json",
+ "generated_at": "2026-08-25T16:17:35.584Z",
+ "count": 35,
+ "accounts": [
+ {
+ "handle": "asseeninhotels",
+ "name": "As Seen in Hotels",
+ "ig_user_id": "17841409119139761"
+ },
+ {
+ "handle": "beverlyhillsvideos",
+ "name": "Designer Wallcoverings",
+ "ig_user_id": "17841408681887144"
+ },
+ {
+ "handle": "borninbeverlyhills",
+ "name": "Born in Beverly Hills",
+ "ig_user_id": "17841408772995866"
+ },
+ {
+ "handle": "brazilliancewallpaper",
+ "name": "Brazilliance Wallpaper and Fabric",
+ "ig_user_id": "17841407025318686"
+ },
+ {
+ "handle": "chinoiseriewallpaper",
+ "name": "Et Cie Chinoiserie Wall Murals",
+ "ig_user_id": "17841407176278551"
+ },
+ {
+ "handle": "commercialwallcoverings",
+ "name": "Commercial Wallcoverings",
+ "ig_user_id": "17841408775091102"
+ },
+ {
+ "handle": "customwallcoverings",
+ "name": "Custom Wall Papers and Wallcoverings",
+ "ig_user_id": "17841408685240867"
+ },
+ {
+ "handle": "designerlaboratory",
+ "name": "Designer Laboratory",
+ "ig_user_id": "17841405610219019"
+ },
+ {
+ "handle": "designerwallcoverings",
+ "name": "DesignerWallcoverings.com",
+ "ig_user_id": "17841400781263605"
+ },
+ {
+ "handle": "fabric_fridays",
+ "name": "Fabric Fridays",
+ "ig_user_id": "17841434885415391"
+ },
+ {
+ "handle": "ffepurchasing",
+ "name": "FFE Purchasing",
+ "ig_user_id": "17841407058348607"
+ },
+ {
+ "handle": "goldleafwallpaper",
+ "name": "Gold Leaf Wallpaper",
+ "ig_user_id": "17841408856660866"
+ },
+ {
+ "handle": "grassclothwallpaper",
+ "name": "Wallpaper Weekly",
+ "ig_user_id": "17841408868631110"
+ },
+ {
+ "handle": "hollywoodwallcoverings",
+ "name": "Hollywood Wallcoverings",
+ "ig_user_id": "17841406772943912"
+ },
+ {
+ "handle": "hospitalitywallcoverings",
+ "name": "Hospitality Wallpaper",
+ "ig_user_id": "17841407188578748"
+ },
+ {
+ "handle": "interiordesignervideos",
+ "name": "Interior Designer Videos",
+ "ig_user_id": "17841408790905574"
+ },
+ {
+ "handle": "linenwallpaper",
+ "name": "Linen Wallpaper",
+ "ig_user_id": "17841408700186822"
+ },
+ {
+ "handle": "metallicwallpaper",
+ "name": "Metallic Wallpaper",
+ "ig_user_id": "17841408654197157"
+ },
+ {
+ "handle": "papelestapiz",
+ "name": "Papel Tapiz",
+ "ig_user_id": "17841407051208925"
+ },
+ {
+ "handle": "patterndesignlab",
+ "name": "Pattern Design Lab",
+ "ig_user_id": "17841405752089037"
+ },
+ {
+ "handle": "philliperomanodesigns",
+ "name": "Phillipe Romano",
+ "ig_user_id": "17841406249858383"
+ },
+ {
+ "handle": "restorationwallpaper",
+ "name": "Restoration Wallpaper",
+ "ig_user_id": "17841408840359842"
+ },
+ {
+ "handle": "retrowalls",
+ "name": "Retro Walls",
+ "ig_user_id": "17841406464478261"
+ },
+ {
+ "handle": "roomsettings",
+ "name": "Room Settings",
+ "ig_user_id": "17841408698848037"
+ },
+ {
+ "handle": "screenprintedwallpaper",
+ "name": "Screen Printed Wallpaper",
+ "ig_user_id": "17841408660869742"
+ },
+ {
+ "handle": "sheltermagazines",
+ "name": "Shelter Magazines",
+ "ig_user_id": "17841406881694118"
+ },
+ {
+ "handle": "suedewallpaper",
+ "name": "Suede Wallpaper",
+ "ig_user_id": "17841408648437136"
+ },
+ {
+ "handle": "textilewallpaper",
+ "name": "Textile Wallpaper",
+ "ig_user_id": "17841408813467105"
+ },
+ {
+ "handle": "thedesignerlibrary",
+ "name": "Designer Library",
+ "ig_user_id": "17841408572952750"
+ },
+ {
+ "handle": "thesetdecorator",
+ "name": "The Set Decorator",
+ "ig_user_id": "17841406774743877"
+ },
+ {
+ "handle": "traditionalwhimsy",
+ "name": "Traditional Whimsy",
+ "ig_user_id": "17841408645959795"
+ },
+ {
+ "handle": "velvetwallpaper",
+ "name": "Velvet Wallpaper",
+ "ig_user_id": "17841408844140138"
+ },
+ {
+ "handle": "wallpaperhistory",
+ "name": "Wallpaper History",
+ "ig_user_id": "17841408790217103"
+ },
+ {
+ "handle": "wallpaperinstallers",
+ "name": "Wallpaper Installers",
+ "ig_user_id": "17841414900195265"
+ },
+ {
+ "handle": "wallpaperwednesdays",
+ "name": "Wallpaper Wednesdays",
+ "ig_user_id": "17841409211532596"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index c461a33..4d2b042 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,8 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
- "build:followlist": "node scripts/build-followlist.js"
+ "build:followlist": "node scripts/build-followlist.js",
+ "build:dwaccounts": "node scripts/build-dw-accounts.js"
},
"dependencies": {
"express": "^4.22.2",
diff --git a/public/dw-accounts.js b/public/dw-accounts.js
new file mode 100644
index 0000000..7650bf6
--- /dev/null
+++ b/public/dw-accounts.js
@@ -0,0 +1,50 @@
+// Shared canonical DW-owned Instagram accounts helper. Every social/account panel
+// uses this so all owned accounts render everywhere (not just where a surface has
+// data for them). Loaded once from index.html; the fetch is memoized so panels
+// re-init cheaply.
+//
+// const accts = await window.MCC_ACCOUNTS.load(); // [{handle, name, ig_user_id}]
+// window.MCC_ACCOUNTS.handles(); // ['asseeninhotels', ...] (after load)
+// window.MCC_ACCOUNTS.byHandle('linenwallpaper'); // {handle,name,...} | undefined
+//
+// merge(dynamicRows, keyFn) unions the canonical accounts with a panel's dynamic
+// data keyed by handle: every canonical account appears, annotated with its dynamic
+// row when one exists (else null) — the one pattern every panel repeats.
+(function () {
+ // Always hit the bare origin — a page opened with embedded URL creds would make
+ // a relative fetch throw "URL that includes credentials" (see app.js).
+ const ORIGIN = location.origin;
+ let cache = null; // resolved [{handle,name,ig_user_id}]
+ let inflight = null; // de-dupe concurrent loads
+
+ async function load() {
+ if (cache) return cache;
+ if (inflight) return inflight;
+ inflight = fetch(ORIGIN + '/api/dw-accounts', { credentials: 'same-origin' })
+ .then(r => r.ok ? r.json() : { accounts: [] })
+ .then(d => { cache = (d && d.accounts) || []; inflight = null; return cache; })
+ .catch(() => { inflight = null; return cache || []; });
+ return inflight;
+ }
+
+ function handles() { return (cache || []).map(a => a.handle); }
+ function byHandle(h) { return (cache || []).find(a => a.handle === h); }
+
+ // Union canonical accounts with a panel's dynamic rows. keyFn(row) → handle.
+ // Returns [{ ...account, data: <matching row | null> }] in canonical (sorted) order,
+ // then any dynamic rows whose handle isn't a known canonical account (rare).
+ function merge(rows, keyFn) {
+ const list = cache || [];
+ const byKey = new Map();
+ (rows || []).forEach(r => { const k = keyFn ? keyFn(r) : r.handle; if (k != null) byKey.set(String(k), r); });
+ const out = list.map(a => ({ ...a, data: byKey.get(a.handle) || null }));
+ const known = new Set(list.map(a => a.handle));
+ (rows || []).forEach(r => {
+ const k = String(keyFn ? keyFn(r) : r.handle);
+ if (k && !known.has(k)) out.push({ handle: k, name: k, ig_user_id: null, data: r });
+ });
+ return out;
+ }
+
+ window.MCC_ACCOUNTS = { load, handles, byHandle, merge };
+})();
diff --git a/public/index.html b/public/index.html
index 7345ac7..2743cfa 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,6 +23,7 @@
</header>
<section id="panel"><div class="loading">Loading…</div></section>
</main>
+<script src="/dw-accounts.js"></script>
<script src="/quickpost.js"></script>
<script src="/app.js"></script>
</body></html>
diff --git a/public/panels/accounts.html b/public/panels/accounts.html
index f8913ea..4400e36 100644
--- a/public/panels/accounts.html
+++ b/public/panels/accounts.html
@@ -65,6 +65,7 @@
.dot-ok{background:#3a8a4a}
.dot-warn{background:#c79a3a}
.dot-off{background:#c04a4a}
+.dot-known{background:#9aa0a6}
/* right detail */
.acc-main{min-height:200px}
@@ -75,6 +76,7 @@
.badge-ok{background:#e3efe0;color:#2f6b34}
.badge-warn{background:#fdf3dc;color:#8a6a1e}
.badge-off{background:#f7e2e2;color:#a53a3a}
+.badge-known{background:#eceef0;color:#5a6066}
.acc-handles{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0}
.acc-note{font-size:13px;line-height:1.5;color:#4a443c;margin:6px 0}
.acc-caveat{background:#fff8ec;border:1px solid #f0e0c0;border-radius:10px;padding:10px 12px;color:#7a5a2a;
diff --git a/public/panels/accounts.js b/public/panels/accounts.js
index ee69a14..f7eca88 100644
--- a/public/panels/accounts.js
+++ b/public/panels/accounts.js
@@ -23,18 +23,65 @@ window.MCC_PANELS['accounts'] = {
{ key: 'staged-only', label: 'Staged only', dot: 'dot-warn' },
{ key: 'needs-creds', label: 'Needs credentials', dot: 'dot-off' },
];
- const DOT = { 'connected': 'dot-ok', 'staged-only': 'dot-warn', 'needs-creds': 'dot-off' };
- const BADGE = { 'connected': ['badge-ok', 'Connected · live'], 'staged-only': ['badge-warn', 'Connected · stages'], 'needs-creds': ['badge-off', 'Needs credentials'] };
+ // 'known' = a DW-owned IG account with no connection record yet — neutral,
+ // not an error. It lives under "All accounts" only (no dedicated facet).
+ const DOT = { 'connected': 'dot-ok', 'staged-only': 'dot-warn', 'needs-creds': 'dot-off', 'known': 'dot-known' };
+ const BADGE = { 'connected': ['badge-ok', 'Connected · live'], 'staged-only': ['badge-warn', 'Connected · stages'], 'needs-creds': ['badge-off', 'Needs credentials'], 'known': ['badge-known', 'Known · not yet connected'] };
const state = { accounts: [], counts: {}, facet: 'all', q: '', selected: null };
const rowTpl = $('#acc-row-tpl');
+ let CANON = []; // the canonical 35 DW-owned Instagram accounts
+
+ // Union the canonical 35 DW-owned IG accounts into the list as individual
+ // Instagram accounts, keyed by handle. An owned handle already surfaced by
+ // the live 'instagram' platform (its `accounts` array) is NOT duplicated;
+ // the rest get a neutral "known / not yet connected" row with a display name.
+ // Additive only — LinkedIn/TikTok/etc. and the live platform rows are untouched.
+ function mergeCanonical(accounts) {
+ if (!CANON.length) return accounts;
+ const ig = accounts.find(a => a.platform === 'instagram');
+ const connectedHandles = new Set(
+ (ig && ig.accounts || []).map(h => String(h).replace(/^@/, '').toLowerCase()));
+ const extras = [];
+ for (const c of CANON) {
+ const h = String(c.handle || '').replace(/^@/, '').toLowerCase();
+ if (!h || connectedHandles.has(h)) continue; // already shown on the live IG row
+ extras.push({
+ platform: 'ig:' + h, // unique key (state.selected/filter key off platform)
+ label: c.name || c.handle, // human display name
+ icon: '📸',
+ connected: false,
+ configured: false,
+ accounts: ['@' + h],
+ caveat: null,
+ needs: 'This DW-owned Instagram account has no connection record yet. Connect Instagram to post to it.',
+ scopes: [],
+ connectUrl: (ig && ig.connectUrl) || null, // reuse the IG OAuth entry point if present
+ howToFix: (ig && ig.howToFix) || null,
+ bucket: 'known',
+ });
+ }
+ return accounts.concat(extras);
+ }
async function load() {
let d;
try { d = await jget('/api/accounts/list'); }
catch (_) { $('#acc-banner').textContent = 'Could not load accounts.'; return; }
- state.accounts = d.accounts || [];
- state.counts = d.counts || {};
+ // Load the canonical 35 once, then union them into the list.
+ if (!CANON.length && window.MCC_ACCOUNTS) {
+ try { CANON = await window.MCC_ACCOUNTS.load(); } catch (_) {}
+ }
+ state.accounts = mergeCanonical(d.accounts || []);
+ // Recompute counts client-side so the 'All accounts' total reflects the union
+ // (the API counts predate the merge). Per-status counts include the 'known' rows.
+ const merged = state.accounts.reduce((m, a) => (m[a.bucket] = (m[a.bucket] || 0) + 1, m), {});
+ state.counts = Object.assign({}, d.counts || {}, {
+ all: state.accounts.length,
+ connected: merged['connected'] || 0,
+ 'staged-only': merged['staged-only'] || 0,
+ 'needs-creds': merged['needs-creds'] || 0,
+ });
const c = state.counts;
$('#acc-banner').innerHTML =
`<b>${c.connected || 0}</b> connected · <b>${c['staged-only'] || 0}</b> staged-only · <b>${c['needs-creds'] || 0}</b> need credentials — of ${c.all || 0} accounts. ` +
diff --git a/public/panels/board.html b/public/panels/board.html
index cf888fa..d94723a 100644
--- a/public/panels/board.html
+++ b/public/panels/board.html
@@ -10,6 +10,11 @@
<label class="muted" style="font-size:12px">Width
<input id="bd-width" type="range" min="240" max="420" step="10" value="300" style="vertical-align:middle">
</label>
+ <label class="muted" style="font-size:12px">IG account
+ <select id="bd-ig-account" style="width:auto;font-size:12px;max-width:220px">
+ <option value="">All owned accounts</option>
+ </select>
+ </label>
<a class="btn gold" href="#composer" style="text-decoration:none;font-size:12.5px;padding:6px 12px">✍️ Compose</a>
<button class="btn ghost" id="bd-clear" style="font-size:12.5px;padding:6px 12px" title="Remove staged / pending / failed cards (keeps posted history)">🗑 Clear staged</button>
<button class="btn ghost" id="bd-refresh" style="font-size:12.5px;padding:6px 12px">↻ Refresh</button>
diff --git a/public/panels/board.js b/public/panels/board.js
index f3df4d6..c84325e 100644
--- a/public/panels/board.js
+++ b/public/panels/board.js
@@ -28,9 +28,45 @@ window.MCC_PANELS['board'] = {
const setWidth = w => { board.style.setProperty('--bd-w', w + 'px'); };
let WIDTH = +(localStorage.getItem('mcc_board_w') || 300);
let SORT = localStorage.getItem('mcc_board_sort') || 'newest';
+ let IG_HANDLE = localStorage.getItem('mcc_board_ig_handle') || '';
$('#bd-width').value = WIDTH; setWidth(WIDTH);
$('#bd-sort').value = SORT;
+ // ── Owned-Instagram account picker (canonical source: window.MCC_ACCOUNTS) ──
+ // Populates the IG-account <select> with all 35 DW-owned @handles. Choosing one
+ // filters the Instagram column's cards to that handle and relabels the column
+ // with the selected @handle; "All owned accounts" restores the full IG stream.
+ // Handle-matching is best-effort against whatever the outbox item carries
+ // (handle / account / igHandle / username), so it's additive — no card is lost
+ // when items don't yet carry a handle unless a specific account is chosen.
+ const igSel = $('#bd-ig-account');
+ async function loadIgAccounts() {
+ if (!igSel) return;
+ let accounts = [];
+ try {
+ if (window.MCC_ACCOUNTS && typeof window.MCC_ACCOUNTS.load === 'function') {
+ accounts = await window.MCC_ACCOUNTS.load();
+ } else {
+ const r = await jget('/api/dw-accounts');
+ accounts = (r && (r.accounts || r.items || (Array.isArray(r) ? r : []))) || [];
+ }
+ } catch { accounts = []; }
+ const opts = ['<option value="">All owned accounts</option>'].concat(
+ (accounts || []).map(a => {
+ const h = String((a && a.handle) || '').replace(/^@/, '');
+ if (!h) return '';
+ const nm = (a && a.name) ? ` — ${esc(a.name)}` : '';
+ return `<option value="${esc(h)}">@${esc(h)}${nm}</option>`;
+ }).filter(Boolean)
+ );
+ igSel.innerHTML = opts.join('');
+ igSel.value = IG_HANDLE;
+ }
+ // normalize any handle-bearing field on an outbox item, lowercased & @-stripped
+ const itemHandle = it => String(
+ (it && (it.handle || it.account || it.igHandle || it.ig_handle || it.username || it.accountHandle)) || ''
+ ).replace(/^@/, '').toLowerCase();
+
// ── IG re-pull health strip (job that keeps published-IG thumbnails durable) ──
// Inserted once above the board; shows last-run time + result + a health dot,
// and a "Run now" fix-it button. The job runs hourly 6am–6pm on Kamatera cron;
@@ -157,15 +193,25 @@ window.MCC_PANELS['board'] = {
}
// ── Social: facebook / instagram / tiktok / youtube from the channels outbox ──
const pf = DATA.platforms[col.id];
- const items = sortItems(DATA.outbox.filter(it => (it.channel || '').toLowerCase() === col.id));
+ let items = DATA.outbox.filter(it => (it.channel || '').toLowerCase() === col.id);
+ // Instagram: when an owned account is picked, filter this column to that handle
+ // and relabel it with the @handle so it reads as a per-account stream.
+ const isIG = col.id === 'instagram';
+ let label = col.label;
+ if (isIG && IG_HANDLE) {
+ const want = IG_HANDLE.toLowerCase();
+ items = items.filter(it => itemHandle(it) === want);
+ label = '@' + IG_HANDLE;
+ }
+ items = sortItems(items);
let pill;
if (pf && pf.connected) pill = { cls: 'on', txt: 'Connected · live' };
else if (pf) pill = { cls: 'stage', txt: 'Stages (not connected)' };
else pill = { cls: 'off', txt: 'Not connected' };
const body = items.length
? items.map(it => cardHTML({ when: it.at, text: it.caption, media: it.mediaUrl, status: it.status })).join('')
- : `<div class="bd-empty">Nothing queued for ${esc(col.label)}.</div>`;
- return { pill, count: items.length, body };
+ : `<div class="bd-empty">Nothing queued for ${esc(label)}.</div>`;
+ return { pill, count: items.length, body, label };
}
function render() {
@@ -175,7 +221,7 @@ window.MCC_PANELS['board'] = {
return `<div class="bd-col ${col.soon ? 'soon' : ''}">
<div class="bd-colhead">
<div class="row1"><span class="ico">${col.icon || '▪'}</span>
- <span class="nm">${esc(col.label)}</span>
+ <span class="nm">${esc(c.label != null ? c.label : col.label)}</span>
<span class="cnt">${c.count}</span></div>
<span class="bd-pill ${c.pill.cls}">${esc(c.pill.txt)}</span>
</div>
@@ -187,6 +233,7 @@ window.MCC_PANELS['board'] = {
// controls
$('#bd-width').addEventListener('input', e => { WIDTH = +e.target.value; setWidth(WIDTH); localStorage.setItem('mcc_board_w', WIDTH); });
$('#bd-sort').addEventListener('change', e => { SORT = e.target.value; localStorage.setItem('mcc_board_sort', SORT); render(); });
+ if (igSel) igSel.addEventListener('change', e => { IG_HANDLE = e.target.value; localStorage.setItem('mcc_board_ig_handle', IG_HANDLE); render(); });
$('#bd-refresh').addEventListener('click', load);
// Clear staged/pending/failed cards (keeps posted history), then reload the board.
$('#bd-clear').addEventListener('click', async () => {
@@ -202,6 +249,7 @@ window.MCC_PANELS['board'] = {
} finally { btn.disabled = false; btn.textContent = label; await load(); }
});
+ await loadIgAccounts();
await load();
loadRepullHealth();
},
diff --git a/public/panels/channels.js b/public/panels/channels.js
index 1b2b3d8..67d6ee7 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -24,6 +24,10 @@ window.MCC_PANELS['channels'] = {
// ── Page picker (select WHICH FB Pages / IG accounts to post to) ──────────
const PAGE_KEY = 'mcc-ch-pages';
let allPages = [], pagesLoaded = false;
+ // Canonical 35 DW-owned IG accounts (loaded once). Every owned IG account must
+ // appear as a known/selectable account even when it's absent from the live
+ // /api/channels/pages response — it shows as "staged / not connected".
+ let CANON = [];
const selectedPages = new Set((() => { try { return JSON.parse(localStorage.getItem(PAGE_KEY) || '[]'); } catch { return []; } })());
const savePageSel = () => { try { localStorage.setItem(PAGE_KEY, JSON.stringify([...selectedPages])); } catch {} };
const metaSelected = () => [...root.querySelectorAll('.ch-t:checked')].some(c => c.value === 'facebook' || c.value === 'instagram');
@@ -52,16 +56,18 @@ window.MCC_PANELS['channels'] = {
const igOnly = igModeOn();
const lbl = $('#ch-pages-label'); if (lbl) lbl.textContent = igOnly ? 'Instagram accounts' : 'Pages';
const list = visiblePages();
+ // staged = owned IG account not in the live connector response → "not connected"
+ const stagedPill = '<span class="pill" style="background:#eef3f8;color:#3a5a8a;font-size:9.5px;padding:1px 7px" title="Owned account, not yet connected to the live posting connector — will stage">staged</span>';
box.innerHTML = list.length ? list.map(p => igOnly
? `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
<input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
<span style="font-weight:600;white-space:nowrap">📸 @${esc(p.igUsername || '')}</span>
- <span class="muted" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px" title="via Page: ${esc(p.name)}">${esc(p.name)}</span>
+ ${p.staged ? stagedPill : `<span class="muted" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px" title="via Page: ${esc(p.name)}">${esc(p.name)}</span>`}
</label>`
: `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
<input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(p.name)}</span>
- ${p.hasIG ? `<span class="muted" title="IG: @${esc(p.igUsername || '')}" style="font-size:11px">📸 @${esc(p.igUsername || '')}</span>` : ''}
+ ${p.staged ? stagedPill : (p.hasIG ? `<span class="muted" title="IG: @${esc(p.igUsername || '')}" style="font-size:11px">📸 @${esc(p.igUsername || '')}</span>` : '')}
</label>`).join('') : `<div class="muted">${igOnly ? 'No Instagram accounts match.' : 'No pages match.'}</div>`;
box.querySelectorAll('.ch-pg').forEach(c => c.addEventListener('change', () => {
if (c.checked) selectedPages.add(c.value); else selectedPages.delete(c.value);
@@ -84,11 +90,22 @@ window.MCC_PANELS['channels'] = {
try {
const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
if (d.error) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(d.error)}</div>`; return; }
- allPages = d.pages || []; pagesLoaded = true;
+ const livePages = d.pages || [];
+ // Merge the canonical 35 owned IG accounts in by handle (matched to a live
+ // page's igUsername). Every owned IG account that ISN'T in the live connector
+ // response is added as a synthetic "staged / not connected" IG account so it's
+ // still a known/selectable target. Live pages are preserved verbatim.
+ if (!CANON.length && window.MCC_ACCOUNTS) { try { CANON = await window.MCC_ACCOUNTS.load(); } catch {} }
+ const liveIgHandles = new Set(livePages.filter(p => p.hasIG && p.igUsername).map(p => String(p.igUsername).toLowerCase()));
+ const staged = (CANON || [])
+ .filter(a => a.handle && !liveIgHandles.has(String(a.handle).toLowerCase()))
+ .map(a => ({ id: 'staged:ig:' + a.handle, name: a.name || a.handle, igUsername: a.handle, ig_user_id: a.ig_user_id || null, hasIG: true, staged: true }));
+ allPages = livePages.concat(staged); pagesLoaded = true;
// drop any stale selections no longer in the account
const ids = new Set(allPages.map(p => p.id));
[...selectedPages].forEach(id => { if (!ids.has(id)) selectedPages.delete(id); });
- $('#ch-pages-note').textContent = `${d.count} Pages · ${d.igCount} with Instagram · fetched ${fmtWhen(d.fetchedAt)}`;
+ const igCount = (d.igCount || 0) + staged.length;
+ $('#ch-pages-note').textContent = `${d.count} Pages · ${igCount} with Instagram${staged.length ? ` (${staged.length} staged / not connected)` : ''} · fetched ${fmtWhen(d.fetchedAt)}`;
renderPages();
} catch (e) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(e.message)}</div>`; }
}
diff --git a/public/panels/composer.html b/public/panels/composer.html
index 1e75702..be994f4 100644
--- a/public/panels/composer.html
+++ b/public/panels/composer.html
@@ -59,6 +59,18 @@
<input id="cmp-fbsearch" class="cmp-search" style="margin-bottom:6px" placeholder="Filter pages…">
<div id="cmp-fbpages-list" class="cmp-fbpages-list"><div class="loading">Loading…</div></div>
</div>
+ <!-- DW-owned IG account targets — the 35 canonical accounts from window.MCC_ACCOUNTS -->
+ <div id="cmp-igaccounts" class="cmp-igaccounts">
+ <div class="cmp-igaccounts-head">
+ <span>📸 DW Instagram accounts</span>
+ <span class="cmp-igaccounts-tools">
+ <button id="cmp-igall" class="cmp-mini" type="button">all</button>
+ <button id="cmp-ignone" class="cmp-mini" type="button">none</button>
+ </span>
+ </div>
+ <input id="cmp-igsearch" class="cmp-search" style="margin-bottom:6px" placeholder="Filter accounts…">
+ <div id="cmp-igaccounts-list" class="cmp-igaccounts-list"><div class="loading">Loading…</div></div>
+ </div>
<label class="cmp-dry"><input type="checkbox" id="cmp-dry" checked> Dry-run (stage, don't post live)</label>
<button class="btn gold" id="cmp-publish" style="width:100%;font-size:14px;padding:11px">Publish</button>
<div id="cmp-result" class="cmp-result"></div>
@@ -103,6 +115,15 @@
font-size:26px;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.6);pointer-events:none}
.cmp-reel .lbl{position:absolute;left:0;right:0;bottom:0;font-size:9.5px;background:rgba(0,0,0,.6);color:#fff;
padding:3px 5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ /* DW IG account picker — mirrors the facebook page picker style */
+ .cmp-igaccounts{border:1px solid var(--line,#ece6da);border-radius:10px;padding:9px;margin-bottom:10px;background:#fbfaf6}
+ .cmp-igaccounts-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;margin-bottom:7px}
+ .cmp-igaccounts-tools{display:flex;gap:5px}
+ .cmp-igaccounts-list{max-height:170px;overflow-y:auto;display:flex;flex-direction:column;gap:3px}
+ .cmp-igrow{display:flex;align-items:center;gap:7px;font-size:12px;padding:4px 6px;border-radius:7px;cursor:pointer}
+ .cmp-igrow:hover{background:#f2ede3}
+ .cmp-igrow .hd{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .cmp-igrow .nm{flex:1;font-size:9.5px;color:#8a8170;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* facebook page picker */
.cmp-fbpages{border:1px solid var(--line,#ece6da);border-radius:10px;padding:9px;margin-bottom:10px;background:#fbfaf6}
.cmp-fbpages-head{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;margin-bottom:7px}
diff --git a/public/panels/composer.js b/public/panels/composer.js
index eca5788..5763a7d 100644
--- a/public/panels/composer.js
+++ b/public/panels/composer.js
@@ -7,7 +7,8 @@ window.MCC_PANELS['composer'] = {
const jget = async u => { try { const r = await fetch(O + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
const jpost = async (u, b) => { try { const r = await fetch(O + u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) }); return await r.json().catch(() => ({})); } catch (e) { return { error: e.message }; } };
- const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set() };
+ const state = { mediaUrl: '', videoUrl: '', src: 'assets', selected: new Set(), pages: new Set(), handles: new Set() };
+ const IG_LS_KEY = 'mccComposerIgHandles'; // persist the account selection across reloads
const NEW_ARRIVALS = 'designerwallcoverings.com/collections/new-arrivals'; // bare → clickable via facets
// ── RIGHT rail: publish targets, connection-aware ──────────────────────────
@@ -71,6 +72,53 @@ window.MCC_PANELS['composer'] = {
});
});
+ // ── DW-owned IG account targets — canonical 35 from window.MCC_ACCOUNTS ─────
+ async function loadIgAccounts() {
+ const box = $('#cmp-igaccounts-list'); if (!box) return;
+ box.innerHTML = '<div class="loading">Loading…</div>';
+ let accts = [];
+ try { accts = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; }
+ catch { accts = []; }
+ if (!accts.length) { box.innerHTML = '<div class="bd-empty" style="font-size:11.5px">No DW accounts available.</div>'; return; }
+ // restore any previously-chosen handles (persisted across reloads)
+ let saved = [];
+ try { saved = JSON.parse(localStorage.getItem(IG_LS_KEY) || '[]'); } catch { saved = []; }
+ const savedSet = new Set(saved);
+ state.handles.clear();
+ box.innerHTML = '';
+ accts.forEach(a => { // already sorted by the helper
+ const h = a.handle || '';
+ if (!h) return;
+ const row = document.createElement('label'); row.className = 'cmp-igrow';
+ const checked = savedSet.has(h);
+ if (checked) state.handles.add(h);
+ row.innerHTML = `<input type="checkbox" data-handle="${esc(h)}"${checked ? ' checked' : ''}>` +
+ `<span class="hd">@${esc(h)}</span>` + (a.name ? `<span class="nm">${esc(a.name)}</span>` : '');
+ const cb = row.querySelector('input');
+ cb.addEventListener('change', () => { cb.checked ? state.handles.add(h) : state.handles.delete(h); persistHandles(); });
+ box.appendChild(row);
+ });
+ persistHandles();
+ }
+ function persistHandles() {
+ try { localStorage.setItem(IG_LS_KEY, JSON.stringify([...state.handles])); } catch {}
+ }
+ $('#cmp-igall').addEventListener('click', () => {
+ root.querySelectorAll('#cmp-igaccounts-list input[data-handle]').forEach(cb => { cb.checked = true; state.handles.add(cb.dataset.handle); });
+ persistHandles();
+ });
+ $('#cmp-ignone').addEventListener('click', () => {
+ root.querySelectorAll('#cmp-igaccounts-list input[data-handle]').forEach(cb => { cb.checked = false; });
+ state.handles.clear(); persistHandles();
+ });
+ $('#cmp-igsearch').addEventListener('input', e => {
+ const q = e.target.value.toLowerCase();
+ root.querySelectorAll('#cmp-igaccounts-list .cmp-igrow').forEach(r => {
+ const t = ((r.querySelector('.hd') || {}).textContent + ' ' + ((r.querySelector('.nm') || {}).textContent || '')).toLowerCase();
+ r.style.display = t.includes(q) ? '' : 'none';
+ });
+ });
+
// ── LEFT rail: assets + banners ────────────────────────────────────────────
async function loadSource(kind) {
const grid = $('#cmp-srcgrid'); grid.innerHTML = '<div class="loading">Loading…</div>';
@@ -207,6 +255,10 @@ window.MCC_PANELS['composer'] = {
channels, caption: cap.value.trim(),
mediaUrl: state.mediaUrl, videoUrl: state.videoUrl,
pages: [...state.pages],
+ // Which DW-owned IG accounts (by @handle) this post targets. Additive field —
+ // the existing gated publish/stage flow is unchanged; this just carries the
+ // user's per-account selection through to the server.
+ handles: [...state.handles],
confirm: !dry, dryRun: dry,
});
if (r && r.results) {
@@ -221,7 +273,7 @@ window.MCC_PANELS['composer'] = {
$('#cmp-publish').disabled = false; $('#cmp-publish').textContent = 'Publish';
});
- $('#cmp-refresh').addEventListener('click', () => { loadTargets(); loadSource(state.src); });
+ $('#cmp-refresh').addEventListener('click', () => { loadTargets(); loadSource(state.src); loadIgAccounts(); });
$('#cmp-search').addEventListener('input', e => {
const q = e.target.value.toLowerCase();
root.querySelectorAll('#cmp-srcgrid .cmp-thumb').forEach(t => {
@@ -230,7 +282,7 @@ window.MCC_PANELS['composer'] = {
});
});
- await Promise.all([loadTargets(), loadSource('assets')]);
+ await Promise.all([loadTargets(), loadSource('assets'), loadIgAccounts()]);
countIt();
// Quick Post — easy per-platform "stage a draft" buttons (gated). Reads the
diff --git a/public/panels/follow-counts.js b/public/panels/follow-counts.js
index ce1b77f..e8465b0 100644
--- a/public/panels/follow-counts.js
+++ b/public/panels/follow-counts.js
@@ -35,45 +35,62 @@ window.MCC_PANELS['follow-counts'] = {
cards.innerHTML = '<div class="loading">Loading accounts…</div>';
let d;
try { d = await api('/accounts'); }
- catch (e) { cards.innerHTML = `<div class="muted-banner">Failed to load: ${esc(e.message)}</div>`; return; }
- banner(d.awaitingCreds, d.source);
- const accts = d.accounts || [];
- if (!accts.length) { cards.innerHTML = '<div class="muted-banner">No DW Instagram accounts configured.</div>'; return; }
- cards.innerHTML = accts.map(a => {
- const l = a.latest;
- const simBadge = (!l || l.simulated)
- ? `<span class="pill" style="background:#fff3cd;color:#7a5b00">awaiting creds</span>`
- : `<span class="pill" style="background:#e6f4ea;color:#1a7f4b">live</span>`;
- return `<div class="card fc-acct" data-handle="${esc(a.handle)}" style="cursor:pointer">
+ catch (e) { d = null; }
+ // Merge the canonical 35 DW-owned accounts with whatever /accounts returned
+ // (keyed by handle) so every owned account renders — with its follower/
+ // following snapshot where Norma has one, and a neutral "no data yet" where
+ // it doesn't (e.g. hosts that can't reach Norma :9810). Additive over the
+ // existing /accounts response; the per-account switch + chart still fire for
+ // accounts that DO have data.
+ await window.MCC_ACCOUNTS.load();
+ banner(d && d.awaitingCreds, d && d.source);
+ const merged = window.MCC_ACCOUNTS.merge((d && d.accounts) || [], a => a.handle);
+ if (!merged.length) { cards.innerHTML = '<div class="muted-banner">No DW Instagram accounts configured.</div>'; return; }
+ cards.innerHTML = merged.map(a => {
+ const row = a.data; // matching /accounts row, or null
+ const l = row && row.latest;
+ const label = (row && row.label) || a.name || a.handle;
+ const primary = !!(row && row.primary);
+ const hasData = !!row;
+ const badge = !hasData
+ ? `<span class="pill" style="background:#eee;color:#777">no data yet</span>`
+ : (!l || l.simulated)
+ ? `<span class="pill" style="background:#fff3cd;color:#7a5b00">awaiting creds</span>`
+ : `<span class="pill" style="background:#e6f4ea;color:#1a7f4b">live</span>`;
+ return `<div class="card fc-acct${hasData ? '' : ' fc-nodata'}" data-handle="${esc(a.handle)}"${hasData ? ' style="cursor:pointer"' : ''}>
<div class="row" style="justify-content:space-between;align-items:flex-start">
<div>
<h2 style="margin:0">@${esc(a.handle)}</h2>
- <div class="muted" style="font-size:12px">${esc(a.label)}${a.primary ? ' · primary' : ''}</div>
- </div>${simBadge}
+ <div class="muted" style="font-size:12px">${esc(label)}${primary ? ' · primary' : ''}</div>
+ </div>${badge}
</div>
<div class="row" style="gap:24px;margin-top:14px">
<div>
<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.04em">Followers</div>
<div style="font-size:30px;font-weight:700;font-family:'Cormorant Garamond',serif;line-height:1.1">${fmtN(l && l.followers)}</div>
- <div style="font-size:12px;margin-top:2px">${a.delta ? fmtDelta(a.delta.followers) : '<span class="muted">—</span>'}</div>
+ <div style="font-size:12px;margin-top:2px">${row && row.delta ? fmtDelta(row.delta.followers) : '<span class="muted">—</span>'}</div>
</div>
<div>
<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.04em">Following</div>
<div style="font-size:30px;font-weight:700;font-family:'Cormorant Garamond',serif;line-height:1.1">${fmtN(l && l.following)}</div>
- <div style="font-size:12px;margin-top:2px">${a.delta ? fmtDelta(a.delta.following) : '<span class="muted">—</span>'}</div>
+ <div style="font-size:12px;margin-top:2px">${row && row.delta ? fmtDelta(row.delta.following) : '<span class="muted">—</span>'}</div>
</div>
</div>
<div class="muted" style="font-size:11px;margin-top:12px"
title="${l ? esc(l.ts) : ''}">
- ${l ? `Last snapshot: ${fmtTime(l.ts)} · ${a.points} day${a.points === 1 ? '' : 's'} of history` : 'No snapshot yet — click “Capture snapshot”.'}
+ ${!hasData ? 'No snapshot yet — Norma has no data for this owned account.'
+ : l ? `Last snapshot: ${fmtTime(l.ts)} · ${row.points} day${row.points === 1 ? '' : 's'} of history`
+ : 'No snapshot yet — click “Capture snapshot”.'}
</div>
- <div class="muted" style="font-size:11px;margin-top:2px">Click to chart growth →</div>
+ <div class="muted" style="font-size:11px;margin-top:2px">${hasData ? 'Click to chart growth →' : ''}</div>
</div>`;
}).join('');
- cards.querySelectorAll('.fc-acct').forEach(c =>
+ // Only accounts WITH data are selectable / chartable.
+ cards.querySelectorAll('.fc-acct:not(.fc-nodata)').forEach(c =>
c.onclick = () => selectAccount(c.dataset.handle));
- // auto-select the first (primary) account for the chart
- if (!selectedHandle && accts[0]) selectAccount(accts[0].handle);
+ // auto-select the first account that has data for the chart
+ const firstWithData = merged.find(a => a.data);
+ if (!selectedHandle && firstWithData) selectAccount(firstWithData.handle);
else if (selectedHandle) renderChart();
}
diff --git a/public/panels/ig-activity.js b/public/panels/ig-activity.js
index 8f5d80f..786ebae 100644
--- a/public/panels/ig-activity.js
+++ b/public/panels/ig-activity.js
@@ -14,6 +14,7 @@ window.MCC_PANELS['ig-activity'] = {
{ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return t || ''; } };
let DATA = [];
+ let CANON = []; // canonical DW-owned accounts (the 35) — so the filter lists ALL owned accounts, not just posted ones
let SORT = localStorage.getItem('ig_sort') || 'ts|desc';
let ACCT = localStorage.getItem('ig_acct') || '';
let target = null; // the post being deleted (modal context)
@@ -69,10 +70,16 @@ window.MCC_PANELS['ig-activity'] = {
}
function fillAccounts() {
- const accts = [...new Set(DATA.map(x => x.handle))].sort();
+ // Union the canonical DW-owned accounts (all 35) with any handle that has
+ // posted — so every owned account is selectable even with zero posts (it
+ // then shows the "No posts." empty state). Canonical accounts with no posts
+ // are marked so it's obvious nothing has published yet.
+ const posted = new Set(DATA.map(x => x.handle));
+ const canon = CANON.map(a => a.handle);
+ const accts = [...new Set([...canon, ...posted])].sort();
const sel = $('#ig-acct');
sel.innerHTML = '<option value="">All accounts</option>' +
- accts.map(a => `<option value="${esc(a)}">@${esc(a)}</option>`).join('');
+ accts.map(a => `<option value="${esc(a)}">@${esc(a)}${posted.has(a) ? '' : ' · no posts'}</option>`).join('');
sel.value = ACCT;
}
@@ -160,6 +167,7 @@ window.MCC_PANELS['ig-activity'] = {
async function load() {
$('#ig-grid').innerHTML = '<div class="loading">Loading posts…</div>';
try {
+ if (!CANON.length && window.MCC_ACCOUNTS) { try { CANON = await window.MCC_ACCOUNTS.load(); } catch {} }
const j = await api('/posts');
DATA = j.posts || [];
$('#ig-meta').textContent =
diff --git a/public/panels/quickpost.html b/public/panels/quickpost.html
index bcdac7c..831265e 100644
--- a/public/panels/quickpost.html
+++ b/public/panels/quickpost.html
@@ -7,6 +7,8 @@
<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>
@@ -26,6 +28,8 @@
.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}
diff --git a/public/panels/quickpost.js b/public/panels/quickpost.js
index 329f9cf..06799f3 100644
--- a/public/panels/quickpost.js
+++ b/public/panels/quickpost.js
@@ -7,9 +7,18 @@ window.MCC_PANELS['quickpost'] = {
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');
+ const media = $('#qp-media'), cap = $('#qp-caption'), preview = $('#qp-preview'), account = $('#qp-account');
const PICON = { instagram: '📷', tiktok: '🎵', facebook: '📘', linkedin: '💼' };
+ // 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 renderPreview() {
const u = media.value.trim();
if (!u) { preview.innerHTML = ''; return; }
@@ -22,7 +31,7 @@ window.MCC_PANELS['quickpost'] = {
// 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(), source: 'quickpost-panel' }),
+ () => ({ 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>';
diff --git a/public/panels/social.html b/public/panels/social.html
index a95ef28..4e9680c 100644
--- a/public/panels/social.html
+++ b/public/panels/social.html
@@ -25,6 +25,12 @@
<label>Time</label>
<input type="time" name="time">
</div>
+ <div style="width:180px">
+ <label>IG account</label>
+ <select name="handle" id="soc-handle">
+ <option value="">— any / not set —</option>
+ </select>
+ </div>
<div style="width:150px">
<label>Status</label>
<select name="status">
@@ -83,6 +89,7 @@
<span class="sc-when muted"></span>
<button class="sc-del" title="Remove" type="button">✕</button>
</div>
+ <div class="sc-handle muted" hidden></div>
<div class="sc-cap"></div>
<div class="sc-tags"></div>
<div class="sc-foot">
@@ -109,6 +116,7 @@
.sc-top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.sc-chan{font-size:12px;font-weight:700}
.sc-when{font-size:11px}
+.sc-handle{font-size:11px;font-weight:600;color:var(--accent);margin-bottom:5px}
.sc-del{margin-left:auto;background:transparent;border:0;color:var(--mut);cursor:pointer;font-size:13px;padding:0 2px}
.sc-del:hover{color:var(--accent)}
.sc-cap{font-size:12.5px;line-height:1.45;color:#3f3a33;white-space:pre-wrap;word-break:break-word;
diff --git a/public/panels/social.js b/public/panels/social.js
index f34c3be..00ffb84 100644
--- a/public/panels/social.js
+++ b/public/panels/social.js
@@ -27,6 +27,25 @@ window.MCC_PANELS['social'] = {
// live vs simulated instead of always saying "dry-run"). Filled by loadStatus().
const state = { posts: [], editingId: null, live: false, backend: null, statusNote: '' };
+ // Populate the add-post form's IG-account picker from the canonical
+ // DW-owned accounts source (35 accounts). Instagram-only; showing it always
+ // is fine — it just names which owned account a scheduled post is for.
+ async function loadAccounts() {
+ const sel = $('#soc-handle');
+ if (!sel) return;
+ let accounts = [];
+ try {
+ accounts = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || [];
+ } catch (_) { accounts = []; }
+ for (const a of accounts) {
+ if (!a || !a.handle) continue;
+ const o = document.createElement('option');
+ o.value = a.handle;
+ o.textContent = '@' + a.handle + (a.name ? ' — ' + a.name : '');
+ sel.appendChild(o);
+ }
+ }
+
async function load() {
try {
const r = await fetch('/api/social/posts').then(r => r.json());
@@ -87,6 +106,12 @@ window.MCC_PANELS['social'] = {
`${CHAN_ICON[p.channel] || '•'} ${CHAN_NAME[p.channel] || p.channel}`;
node.querySelector('.sc-when').textContent = whenLabel(p);
+ const handleEl = node.querySelector('.sc-handle');
+ if (handleEl && p.handle) {
+ handleEl.textContent = '@' + p.handle;
+ handleEl.hidden = false;
+ }
+
const cap = node.querySelector('.sc-cap');
cap.textContent = p.caption || '(no caption yet)';
if (!p.caption) cap.classList.add('muted');
@@ -213,6 +238,7 @@ window.MCC_PANELS['social'] = {
function startEdit(p) {
state.editingId = p.id;
form.channel.value = p.channel || 'instagram';
+ if (form.handle) form.handle.value = p.handle || '';
form.date.value = p.date || '';
form.time.value = p.time || '';
form.status.value = p.status || 'idea';
@@ -239,6 +265,7 @@ window.MCC_PANELS['social'] = {
const body = {
id: state.editingId || undefined,
channel: fd.get('channel'),
+ handle: (fd.get('handle') || '').toString(),
date: (fd.get('date') || '').toString(),
time: (fd.get('time') || '').toString(),
status: fd.get('status'),
@@ -283,6 +310,7 @@ window.MCC_PANELS['social'] = {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: state.editingId || undefined, channel,
+ handle: (fd.get('handle') || '').toString(),
date: (fd.get('date') || '').toString(), time: (fd.get('time') || '').toString(),
caption, hashtags: (fd.get('hashtags') || '').toString(),
imageUrl: (fd.get('imageUrl') || '').toString().trim(),
@@ -347,5 +375,6 @@ window.MCC_PANELS['social'] = {
loadStatus();
load();
loadBestTimes();
+ loadAccounts(); // populate the DW-owned IG account picker (all 35)
},
};
diff --git a/public/panels/vendors.html b/public/panels/vendors.html
index ec73221..0fe80e2 100644
--- a/public/panels/vendors.html
+++ b/public/panels/vendors.html
@@ -7,8 +7,13 @@
<div style="margin-left:auto"><label>Sort</label>
<select id="vs-sort"><option value="followers">Followers ↓</option><option value="brand">Brand A→Z</option><option value="code">Vendor code</option></select></div>
</div>
+<div class="card" id="vend-owned-card">
+ <h2>Owned · DW Fleet</h2>
+ <div class="muted" style="margin-bottom:10px">Our own Instagram accounts across the DW fleet. Click any handle to open the account.</div>
+ <div id="vend-owned-rows"><div class="muted">Loading owned accounts…</div></div>
+</div>
<div class="card">
- <h2>DW + Vendor Instagram accounts</h2>
- <div class="muted" style="margin-bottom:10px">Click any handle to open the account. Use this roster for competitive/social reporting.</div>
+ <h2>Vendor brands</h2>
+ <div class="muted" style="margin-bottom:10px">External vendor / brand accounts (suppliers & competitors — NOT owned). Click any handle to open the account. Use this roster for competitive/social reporting.</div>
<div id="vend-rows"></div>
</div>
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index d85f370..4884940 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -17,6 +17,28 @@ window.MCC_PANELS['vendors'] = {
};
setStats();
+ // Owned · DW Fleet — all 35 DW-OWNED Instagram accounts from the canonical
+ // source (window.MCC_ACCOUNTS). Rendered as a native vend-block list, clearly
+ // separated ABOVE the external vendor roster. Additive; never touches vendors.
+ (async () => {
+ const host = root.querySelector('#vend-owned-rows');
+ if (!host) return;
+ let owned = [];
+ try { owned = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; }
+ catch { host.innerHTML = '<div class="muted">Failed to load owned accounts.</div>'; return; }
+ if (!owned.length) { host.innerHTML = '<div class="muted">No owned accounts found.</div>'; return; }
+ const rows = [...owned].sort((a, b) => String(a.name || a.handle).localeCompare(String(b.name || b.handle)));
+ host.innerHTML = rows.map(a => {
+ const h = esc(a.handle);
+ return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px;background:#faf6ee">
+ <div class="row" style="justify-content:space-between;align-items:center">
+ <div style="min-width:200px"><b>${esc(a.name || a.handle)}</b> <span class="pill">OURS</span></div>
+ <div style="flex:1"><a class="lnk" href="https://www.instagram.com/${h}/" target="_blank" rel="noopener noreferrer">@${h} ↗</a></div>
+ </div>
+ </div>`;
+ }).join('');
+ })();
+
// Last 3 posts rendered INLINE as a stacked listing directly below each
// vendor row (not a photo grid) — thumb + caption ↗ + date · likes · comments.
const postList = (r) => {
diff --git a/scripts/build-dw-accounts.js b/scripts/build-dw-accounts.js
new file mode 100644
index 0000000..a034f6b
--- /dev/null
+++ b/scripts/build-dw-accounts.js
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+/*
+ * build-dw-accounts.js — regenerate data/dw-ig-accounts.json, the CANONICAL list
+ * of every DW-owned Instagram account, from Norma's postable-accounts registry.
+ *
+ * Why this exists: the Command Center's panels each used to derive their own
+ * account list (the IG-Activity dropdown from post handles only, follow-counts
+ * live from Norma, etc.), so an account with no posts/data silently vanished from
+ * a surface. This script promotes ONE source of truth that every social/account
+ * panel reads via GET /api/dw-accounts.
+ *
+ * Source (overridable via env):
+ * NORMA_ACCOUNTS (default ~/Projects/Norma/agents/instagram-agent/accounts.json)
+ *
+ * Runs on Mac2 (where Norma lives). The OUT file is committed so it ships to
+ * Kamatera, where Norma is unreachable — same reason the IG-delete flow degrades
+ * on prod. If the source is missing, the existing committed JSON is preserved.
+ * Idempotent; safe to run repeatedly.
+ *
+ * npm run build:dwaccounts
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const HOME = os.homedir();
+const SRC = process.env.NORMA_ACCOUNTS ||
+ path.join(HOME, 'Projects', 'Norma', 'agents', 'instagram-agent', 'accounts.json');
+const OUT = path.join(__dirname, '..', 'data', 'dw-ig-accounts.json');
+
+const prev = fs.existsSync(OUT) ? JSON.parse(fs.readFileSync(OUT, 'utf8')) : { accounts: [] };
+
+if (!fs.existsSync(SRC)) {
+ console.log(`• Norma source missing (${SRC}) — preserving existing ${(prev.accounts || []).length} accounts`);
+ process.exit(0);
+}
+
+const reg = JSON.parse(fs.readFileSync(SRC, 'utf8'));
+const src = reg.accounts || {};
+
+// Normalize to a flat, sorted list. `name` is the human display name (page_name),
+// falling back to the handle. Keep ig_user_id so panels that publish/track by the
+// Graph id can join without re-reading Norma.
+const accounts = Object.values(src)
+ .filter(a => a && a.handle)
+ .map(a => ({
+ handle: String(a.handle),
+ name: String(a.page_name || a.handle),
+ ig_user_id: a.ig_user_id || null,
+ }))
+ .sort((x, y) => x.handle.localeCompare(y.handle));
+
+const out = {
+ source: 'Norma/agents/instagram-agent/accounts.json',
+ generated_at: new Date().toISOString(),
+ count: accounts.length,
+ accounts,
+};
+
+const tmp = OUT + '.tmp';
+fs.writeFileSync(tmp, JSON.stringify(out, null, 2));
+fs.renameSync(tmp, OUT);
+console.log(`wrote ${path.relative(process.cwd(), OUT)} — ${accounts.length} DW-owned accounts`);
+accounts.forEach(a => console.log(` @${a.handle} (${a.name})`));
diff --git a/server.js b/server.js
index cf56b06..ec9a15a 100644
--- a/server.js
+++ b/server.js
@@ -107,6 +107,21 @@ app.post('/api/clients-notes', (req, res) => {
try { writeNotes(all); } catch { return res.status(500).json({ error: 'write failed' }); }
res.json({ ok: true });
});
+// ── Canonical DW-owned Instagram accounts (the 35) ───────────────────────────
+// One source of truth every social/account panel reads so all owned accounts show
+// up everywhere — not just the ones that happen to have posts/data on a surface.
+// Not a module (no nav panel of its own), so it mounts here like clients-notes.
+// File: data/dw-ig-accounts.json — regenerated by scripts/build-dw-accounts.js.
+const DW_ACCOUNTS_FILE = path.join(__dirname, 'data', 'dw-ig-accounts.json');
+app.get('/api/dw-accounts', (_req, res) => {
+ try {
+ const d = JSON.parse(fs.readFileSync(DW_ACCOUNTS_FILE, 'utf8'));
+ res.json({ ok: true, count: (d.accounts || []).length, generated_at: d.generated_at || null, accounts: d.accounts || [] });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: 'dw-accounts unavailable', accounts: [] });
+ }
+});
+
app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
← aa6a459 auto-data-snapshot: 2026-08-25T08:55:17 (1 data files) — pub
·
back to Marketing Command Center
·
auto-data-snapshot: 2026-08-25T09:32:22 (1 data files) — pub 82a9086 →