← back to Rentv 2026
Admin: add Social Media command center at /social (LinkedIn-forward, draft-only)
d83ecd5cf7fceedd7aab622bf7537205dad8ed58 · 2026-07-28 13:15:14 -0700 · Steve Abrams
- /social page + /api/social (channels/calendar/best-times/ads from folded consulting data) + draft composer (POST/DELETE /api/social/draft -> data/social-drafts.json)
- LinkedIn + Facebook wired live (real RENTV company URLs); IG/X/TikTok/YouTube/Pinterest shown as not-connected
- DRAFT-ONLY by design: no live-posting endpoint (distribution stays gated until Steve enables)
- backend hub card + INTERNAL_STATIC guard + toggle backend-regex updated; all admin-only (user=403)
TK-10013
Files touched
A data/social-drafts.jsonM public/backend.htmlA public/social.htmlM public/toggle.jsM server.js
Diff
commit d83ecd5cf7fceedd7aab622bf7537205dad8ed58
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 28 13:15:14 2026 -0700
Admin: add Social Media command center at /social (LinkedIn-forward, draft-only)
- /social page + /api/social (channels/calendar/best-times/ads from folded consulting data) + draft composer (POST/DELETE /api/social/draft -> data/social-drafts.json)
- LinkedIn + Facebook wired live (real RENTV company URLs); IG/X/TikTok/YouTube/Pinterest shown as not-connected
- DRAFT-ONLY by design: no live-posting endpoint (distribution stays gated until Steve enables)
- backend hub card + INTERNAL_STATIC guard + toggle backend-regex updated; all admin-only (user=403)
TK-10013
---
data/social-drafts.json | 1 +
public/backend.html | 1 +
public/social.html | 146 ++++++++++++++++++++++++++++++++++++++++++++++++
public/toggle.js | 2 +-
server.js | 34 ++++++++++-
5 files changed, 182 insertions(+), 2 deletions(-)
diff --git a/data/social-drafts.json b/data/social-drafts.json
new file mode 100644
index 00000000..0637a088
--- /dev/null
+++ b/data/social-drafts.json
@@ -0,0 +1 @@
+[]
\ No newline at end of file
diff --git a/public/backend.html b/public/backend.html
index 70167605..7f561123 100644
--- a/public/backend.html
+++ b/public/backend.html
@@ -47,6 +47,7 @@
<div class="sect">Growth & Comms</div>
<div class="grid">
+ <a class="card" href="/social"><div class="ico">📣</div><b>Social Media</b><span>LinkedIn · X · Instagram — channels, calendar & drafts</span></a>
<a class="card" href="/consulting"><div class="ico">🚀</div><b>Consulting</b><span>Growth deliverable · concepts · intake · command center</span></a>
<a class="card" href="/press"><div class="ico">🎬</div><b>Video Library</b><span>Films, walkthroughs & recaps + launch captures</span></a>
</div>
diff --git a/public/social.html b/public/social.html
new file mode 100644
index 00000000..3ab10dcf
--- /dev/null
+++ b/public/social.html
@@ -0,0 +1,146 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>RENTV — Social Media (Admin)</title>
+<style>
+ :root { --ink:#0d1b2a; --muted:#5b6b7b; --line:#e4e9ef; --bg:#f6f8fb; --accent:#0a6cff; --li:#0a66c2; }
+ * { box-sizing:border-box; }
+ body { margin:0; font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:var(--ink); background:var(--bg); }
+ header { padding:22px 28px; border-bottom:1px solid var(--line); background:#fff; display:flex; align-items:baseline; gap:14px; }
+ header h1 { margin:0; font-size:20px; }
+ header .tag { font-size:11px; text-transform:uppercase; letter-spacing:.12em; color:#fff; background:var(--accent); padding:3px 8px; border-radius:99px; }
+ header a.back { margin-left:auto; font-size:13px; color:var(--accent); text-decoration:none; font-weight:600; }
+ main { max-width:1180px; margin:0 auto; padding:24px 28px 60px; display:grid; grid-template-columns:1.4fr 1fr; gap:22px; }
+ @media (max-width:900px){ main{ grid-template-columns:1fr; } }
+ .col { display:flex; flex-direction:column; gap:22px; }
+ h2 { font-size:12px; text-transform:uppercase; letter-spacing:.11em; color:var(--muted); margin:0 0 12px; }
+ .panel { background:#fff; border:1px solid var(--line); border-radius:14px; padding:18px 20px; }
+ .gate { background:#fff8e6; border:1px solid #f0dca0; color:#7a5c12; border-radius:10px; padding:10px 14px; font-size:13px; margin-bottom:6px; }
+ .chan { display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); gap:10px; }
+ .chan a, .chan div { display:flex; align-items:center; gap:9px; border:1px solid var(--line); border-radius:10px; padding:11px 12px; text-decoration:none; color:inherit; font-size:13.5px; }
+ .chan .on { border-color:#bfe0c4; background:#f2fbf4; }
+ .chan .off { color:var(--muted); background:#fafbfc; }
+ .chan b { font-weight:600; }
+ .chan .st { margin-left:auto; font-size:10.5px; text-transform:uppercase; letter-spacing:.08em; }
+ .chan .st.y { color:#1a7f37; } .chan .st.n { color:#9aa7b4; }
+ .li { border:1.5px solid var(--li) !important; background:#eef5fc !important; }
+ label { display:block; font-size:12px; color:var(--muted); margin:12px 0 5px; }
+ select, textarea, input { width:100%; border:1px solid var(--line); border-radius:9px; padding:9px 11px; font:inherit; color:var(--ink); background:#fff; }
+ textarea { min-height:96px; resize:vertical; }
+ .row { display:flex; gap:10px; align-items:center; }
+ button { border:0; background:var(--accent); color:#fff; font-weight:600; border-radius:9px; padding:10px 18px; cursor:pointer; font-size:13.5px; }
+ button.ghost { background:#eef2f7; color:var(--ink); }
+ .draft { border:1px solid var(--line); border-radius:10px; padding:11px 13px; margin-top:10px; }
+ .draft .top { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); }
+ .draft .pf { color:#fff; background:var(--li); border-radius:6px; padding:2px 8px; font-size:11px; font-weight:600; }
+ .draft p { margin:7px 0 0; font-size:14px; white-space:pre-wrap; }
+ .draft .del { margin-left:auto; color:#c0392b; cursor:pointer; font-size:12px; background:none; padding:2px 4px; }
+ table { width:100%; border-collapse:collapse; font-size:13px; }
+ th, td { text-align:left; padding:8px 6px; border-bottom:1px solid var(--line); vertical-align:top; }
+ th { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
+ .badge { font-size:10.5px; background:#fff3cd; color:#8a6d1a; padding:2px 7px; border-radius:99px; white-space:nowrap; }
+ .times span { display:inline-block; background:#eef2f7; border-radius:99px; padding:4px 10px; margin:3px 4px 0 0; font-size:12.5px; }
+ .plt { font-weight:600; font-size:12.5px; margin-top:10px; }
+</style>
+</head>
+<body>
+<header>
+ <h1>RENTV Social</h1><span class="tag">Admin</span>
+ <a class="back" href="/backend">← Backend</a>
+</header>
+<main>
+ <div class="col">
+ <div class="panel">
+ <h2>Channels</h2>
+ <div class="chan" id="channels"></div>
+ </div>
+ <div class="panel">
+ <h2>Compose a post <span style="color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">(draft only)</span></h2>
+ <div class="gate">⚠ Distribution is <b>draft-only</b>. Posts are saved here for review — nothing publishes to any channel until you enable it.</div>
+ <div class="row">
+ <div style="flex:1"><label>Platform</label>
+ <select id="pf"><option>LinkedIn</option><option>X (Twitter)</option><option>Instagram</option><option>Facebook</option></select></div>
+ <div style="flex:2"><label>Topic (optional)</label><input id="topic" placeholder="e.g. Top weekend CRE transaction"></div>
+ </div>
+ <label>Post text</label>
+ <textarea id="text" placeholder="Draft your CRE deal / news post…"></textarea>
+ <div class="row" style="margin-top:12px"><button id="save">Save draft</button><span id="msg" style="color:var(--muted);font-size:13px"></span></div>
+ <div id="drafts"></div>
+ </div>
+ </div>
+
+ <div class="col">
+ <div class="panel">
+ <h2>Content calendar</h2>
+ <table id="cal"><thead><tr><th>Day</th><th>Platform</th><th>Topic</th><th>Status</th></tr></thead><tbody></tbody></table>
+ </div>
+ <div class="panel">
+ <h2>Best times to post</h2>
+ <div id="times"></div>
+ </div>
+ <div class="panel">
+ <h2>Paid ads</h2>
+ <p id="ads" style="margin:0;font-size:13.5px;color:var(--muted)"></p>
+ </div>
+ </div>
+</main>
+<script>
+ var PLAT = [
+ ['linkedin','LinkedIn','in'], ['x','X (Twitter)','𝕏'], ['instagram','Instagram','◎'],
+ ['facebook','Facebook','f'], ['youtube','YouTube','▶'], ['tiktok','TikTok','♪'], ['pinterest','Pinterest','P']
+ ];
+ var S = {};
+ function fmtTopic(t){ return (t||'').replace(/</g,'<'); }
+
+ fetch('/api/social',{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(d){
+ S = d;
+ // channels — LinkedIn featured first
+ var ch = d.channels||{};
+ document.getElementById('channels').innerHTML = PLAT.map(function(p){
+ var url = ch[p[0]] || (p[0]==='x' ? ch.twitter : null);
+ var li = p[0]==='linkedin' ? ' li' : '';
+ if (url) return '<a class="on'+li+'" href="'+url+'" target="_blank"><b>'+p[2]+'</b> '+p[1]+'<span class="st y">live</span></a>';
+ return '<div class="off'+li+'"><b>'+p[2]+'</b> '+p[1]+'<span class="st n">—</span></div>';
+ }).join('');
+ // calendar
+ document.querySelector('#cal tbody').innerHTML = (d.calendar||[]).map(function(c){
+ return '<tr><td>'+c.day+'</td><td>'+c.platform+'</td><td>'+fmtTopic(c.topic)+'</td><td><span class="badge">'+(c.status||'').replace(/ —.*/,'')+'</span></td></tr>';
+ }).join('') || '<tr><td colspan=4 style="color:var(--muted)">No calendar yet.</td></tr>';
+ // best times
+ var bt = d.bestTimes||{};
+ document.getElementById('times').innerHTML = Object.keys(bt).filter(function(k){return Array.isArray(bt[k]);}).map(function(k){
+ return '<div class="plt">'+k[0].toUpperCase()+k.slice(1)+'</div><div class="times">'+bt[k].map(function(t){return '<span>'+t+'</span>';}).join('')+'</div>';
+ }).join('') + (bt.note?'<div style="color:var(--muted);font-size:12px;margin-top:8px">'+bt.note+'</div>':'');
+ // ads
+ var a = d.ads||{};
+ document.getElementById('ads').textContent = a.note || (a.advertises_on&&a.advertises_on.length ? 'Advertising on: '+a.advertises_on.join(', ') : 'No paid campaigns detected.');
+ renderDrafts(d.drafts||[]);
+ });
+
+ function renderDrafts(list){
+ document.getElementById('drafts').innerHTML = (list||[]).map(function(x){
+ var when = new Date(x.created_at).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+ return '<div class="draft"><div class="top"><span class="pf">'+x.platform+'</span>'+(x.topic?'<span>'+fmtTopic(x.topic)+'</span>':'')+'<span>· 🕓 '+when+'</span><button class="del" data-id="'+x.id+'">Delete</button></div><p>'+fmtTopic(x.text)+'</p></div>';
+ }).join('');
+ document.querySelectorAll('.del').forEach(function(b){ b.onclick=function(){
+ fetch('/api/social/draft/'+b.dataset.id,{method:'DELETE',credentials:'same-origin'}).then(function(){ reload(); });
+ };});
+ }
+ function reload(){ fetch('/api/social',{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(d){ renderDrafts(d.drafts||[]); }); }
+
+ document.getElementById('save').onclick = function(){
+ var text=document.getElementById('text').value.trim();
+ var msg=document.getElementById('msg');
+ if(!text){ msg.textContent='Write something first.'; return; }
+ fetch('/api/social/draft',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({platform:document.getElementById('pf').value,topic:document.getElementById('topic').value,text:text})})
+ .then(function(r){return r.json();}).then(function(res){
+ if(res.ok){ document.getElementById('text').value=''; document.getElementById('topic').value=''; msg.textContent='Draft saved ✓'; reload(); setTimeout(function(){msg.textContent='';},2500); }
+ else msg.textContent = res.error||'Error';
+ });
+ };
+</script>
+</body>
+</html>
diff --git a/public/toggle.js b/public/toggle.js
index cd37fc79..bb3064a4 100644
--- a/public/toggle.js
+++ b/public/toggle.js
@@ -4,7 +4,7 @@
(function () {
if (window.__rentvToggle) return; window.__rentvToggle = 1;
// Paths that count as "backend" for active-state highlighting.
- var BACKEND = /^\/(backend|desk|desk-admin|admin|versions|audience|consulting|press)(\/|$|\.html)/i;
+ var BACKEND = /^\/(backend|desk|desk-admin|admin|versions|audience|consulting|press|social)(\/|$|\.html)/i;
fetch('/api/me', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : { role: 'user' }; })
diff --git a/server.js b/server.js
index 58421bbf..da73a141 100644
--- a/server.js
+++ b/server.js
@@ -608,11 +608,43 @@ app.post('/consulting/api/admin/:bucket', adminOnly, (req, res) => {
// /press — the rentv-promos recap video + launch captures, admin/press only.
app.get('/press', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'press', 'index.html')));
+// ── Social Media command center (/social): channels + content calendar + best-times + drafts.
+// DRAFT-ONLY by design (no live posting endpoint) — distribution stays gated until Steve enables.
+// Admin-only. Reads the folded consulting social data; drafts persist to data/social-drafts.json.
+app.get('/social', adminOnly, (_q, r) => sendPage(r, path.join(PUB, 'social.html')));
+app.get('/api/social', adminOnly, (_q, res) => {
+ res.set('Cache-Control', 'private, max-age=30');
+ res.json({
+ channels: cRead('socials.json', {}),
+ bestTimes: cRead('best-times.json', {}),
+ calendar: cRead('content-calendar.json', []),
+ ads: cRead('ads.json', {}),
+ drafts: readJSON('social-drafts.json', []),
+ });
+});
+const SOCIAL_DRAFTS = path.join(DATA, 'social-drafts.json');
+app.post('/api/social/draft', adminOnly, (req, res) => {
+ const b = req.body || {};
+ const text = String(b.text || '').trim().slice(0, 3000);
+ if (!text) return res.status(400).json({ ok: false, error: 'text required' });
+ const drafts = readJSON('social-drafts.json', []);
+ const draft = { id: 'sd' + Date.now().toString(36), platform: String(b.platform || 'LinkedIn').slice(0, 30), topic: String(b.topic || '').slice(0, 200), text, status: 'draft', created_at: new Date().toISOString() };
+ drafts.unshift(draft);
+ try { fs.writeFileSync(SOCIAL_DRAFTS, JSON.stringify(drafts, null, 1)); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, draft });
+});
+app.delete('/api/social/draft/:id', adminOnly, (req, res) => {
+ const drafts = readJSON('social-drafts.json', []);
+ const n = drafts.filter(d => d.id !== String(req.params.id));
+ try { fs.writeFileSync(SOCIAL_DRAFTS, JSON.stringify(n, null, 1)); } catch { return res.status(500).json({ ok: false, error: 'save failed' }); }
+ res.json({ ok: true, removed: drafts.length - n.length });
+});
+
// ── Defense-in-depth static guard ───────────────────────────────────────────
// The data APIs above are already admin-only; this additionally blocks user-tier
// logins from loading any INTERNAL static shell/asset directly (desk, audience,
// admin, versions, consulting, press). Front-page assets pass straight through.
-const INTERNAL_STATIC = /^\/(desk(\.html)?$|desk-assets\/|desk-admin|audience\.html|admin(\/|$|\.html)|versions(\/|$)|consulting(\/|$)|press(\/|$))/i;
+const INTERNAL_STATIC = /^\/(desk(\.html)?$|desk-assets\/|desk-admin|audience\.html|admin(\/|$|\.html)|versions(\/|$)|consulting(\/|$)|press(\/|$)|social(\.html)?$)/i;
app.use((req, res, next) => {
if (req.role === 'admin') return next();
if (INTERNAL_STATIC.test(req.path)) return res.status(403).send('Admin only — this area requires an admin login.');
← c7d9db4c Video Library: voiced walkthrough (Steve cloned VO) + create
·
back to Rentv 2026
·
Video Library: refresh walkthrough with clean-header capture 9f5655f8 →