← back to Prestige Car Wash
admin: Compose & Post tab — create socials + post from assets in one place
e65a96d1c9fee7017e5749e69f9cb4d6084c806c · 2026-07-19 07:29:46 -0700 · Steve Abrams
Local scaffold only (live build owned by another session; NOT deployed).
Feature — a new '✨ Compose & Post' command-center tab that turns the existing
media library + best-times into a one-screen social workflow:
- asset picker over /media (real photos + service videos, videos auto-paired
to their poster stills)
- one-click caption templates (data/social-templates.json) with {service}/{blurb}
fill-in from the real services, + click-to-add hashtag sets
- pick platforms (IG/FB/Google/TikTok/Nextdoor/Yelp) → best-time hint per platform
from best-times.json + one-click 'Open <network>' into each native composer
(auto-copies the caption), Copy caption, Download asset, Save draft
- DRAFT-ONLY by rule: never auto-posts; drafts persist to reports/social-drafts.jsonl
and list back as a mini content calendar
New APIs: GET /api/admin/media, /api/admin/social/templates, /api/admin/social/drafts,
POST /api/admin/social/draft (all admin-gated, read-only media scan, append-only drafts).
Refactor / fixes:
- sortServices 'featured' NaN bug (undefined-featured → unstable sort) → boolean coerce
- enc() now escapes & > " (not just <) — hardens the lead-render surface
- Overview 'Google Places' tag now reflects real credential presence, not a hardcoded 'mock'
Verified locally on :9911: syntax OK, all 4 APIs return correct data (18 assets,
6 platforms/5 templates, draft save+list), admin page serves with the tab, auth gate 401s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A data/social-templates.jsonM public/admin/index.htmlM server.js
Diff
commit e65a96d1c9fee7017e5749e69f9cb4d6084c806c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 19 07:29:46 2026 -0700
admin: Compose & Post tab — create socials + post from assets in one place
Local scaffold only (live build owned by another session; NOT deployed).
Feature — a new '✨ Compose & Post' command-center tab that turns the existing
media library + best-times into a one-screen social workflow:
- asset picker over /media (real photos + service videos, videos auto-paired
to their poster stills)
- one-click caption templates (data/social-templates.json) with {service}/{blurb}
fill-in from the real services, + click-to-add hashtag sets
- pick platforms (IG/FB/Google/TikTok/Nextdoor/Yelp) → best-time hint per platform
from best-times.json + one-click 'Open <network>' into each native composer
(auto-copies the caption), Copy caption, Download asset, Save draft
- DRAFT-ONLY by rule: never auto-posts; drafts persist to reports/social-drafts.jsonl
and list back as a mini content calendar
New APIs: GET /api/admin/media, /api/admin/social/templates, /api/admin/social/drafts,
POST /api/admin/social/draft (all admin-gated, read-only media scan, append-only drafts).
Refactor / fixes:
- sortServices 'featured' NaN bug (undefined-featured → unstable sort) → boolean coerce
- enc() now escapes & > " (not just <) — hardens the lead-render surface
- Overview 'Google Places' tag now reflects real credential presence, not a hardcoded 'mock'
Verified locally on :9911: syntax OK, all 4 APIs return correct data (18 assets,
6 platforms/5 templates, draft save+list), admin page serves with the tab, auth gate 401s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
data/social-templates.json | 33 ++++++++++
public/admin/index.html | 146 +++++++++++++++++++++++++++++++++++++++++++--
server.js | 59 ++++++++++++++++++
3 files changed, 234 insertions(+), 4 deletions(-)
diff --git a/data/social-templates.json b/data/social-templates.json
new file mode 100644
index 0000000..0afa897
--- /dev/null
+++ b/data/social-templates.json
@@ -0,0 +1,33 @@
+{
+ "platforms": [
+ { "id": "instagram", "label": "Instagram", "emoji": "📸", "handle": "@prestigecwash", "composeUrl": "https://www.instagram.com/", "note": "Post from the IG app or Meta Business Suite — attach the downloaded asset, paste the caption." },
+ { "id": "facebook", "label": "Facebook", "emoji": "👍", "composeUrl": "https://www.facebook.com/", "note": "Meta Business Suite → create post; attach asset, paste caption." },
+ { "id": "google", "label": "Google Business", "emoji": "🟢", "composeUrl": "https://business.google.com/posts", "note": "Add an Update post with the asset + caption; great for local search." },
+ { "id": "tiktok", "label": "TikTok", "emoji": "🎵", "composeUrl": "https://www.tiktok.com/upload", "note": "Best with the video assets — upload, paste caption + hashtags." },
+ { "id": "nextdoor", "label": "Nextdoor", "emoji": "🏡", "composeUrl": "https://nextdoor.com/news_feed/", "note": "Post to the Reseda / SFV neighborhood — hyper-local reach." },
+ { "id": "yelp", "label": "Yelp", "emoji": "⭐", "composeUrl": "https://biz.yelp.com/", "note": "Business owner → add photos / a post from the biz dashboard." }
+ ],
+ "hashtag_sets": {
+ "core": ["#PrestigeCarWash", "#Reseda", "#SanFernandoValley", "#SFV", "#CarWashNearMe", "#818"],
+ "detail": ["#CeramicCoating", "#AutoDetailing", "#CarDetailing", "#PaintCorrection", "#DetailingLife"],
+ "community": ["#SupportLocal", "#ResedaCA", "#ShopLocalSFV", "#ValleyLife"],
+ "deal": ["#CarWashDeal", "#Unlimited", "#WashClub", "#LADeals"]
+ },
+ "templates": [
+ { "id": "tpl-service-spotlight", "name": "Service spotlight", "theme": "Promo",
+ "caption": "✨ {service} at Prestige Hand Car Wash — {blurb}\n\nFlat, all-in pricing. No upsell pressure, ever. 🚗💦\n📍 7601 Reseda Blvd, Reseda · ⭐ 4.3 on Google\n📞 Book: (747) 265-6293",
+ "hashtags": ["core", "detail"] },
+ { "id": "tpl-before-after", "name": "Before / After proof", "theme": "Proof",
+ "caption": "Before → After: the Prestige difference. 🚗✨\n\n{service}, done right the first time by hand. Reseda’s no-pressure hand car wash.\n📞 (747) 265-6293 · 📍 7601 Reseda Blvd",
+ "hashtags": ["core", "detail"] },
+ { "id": "tpl-weekend-deal", "name": "Weekend / demand-window deal", "theme": "Deal",
+ "caption": "It’s a perfect day for a wash ☀️🚗\n\nRoll through Prestige this weekend — hand wash, spot-free rinse, hand dry. In and out, no upsell.\n📍 7601 Reseda Blvd · ⏰ open today · 📞 (747) 265-6293",
+ "hashtags": ["core", "deal", "community"] },
+ { "id": "tpl-membership", "name": "Unlimited membership", "theme": "Retention",
+ "caption": "Wash more, pay less. 🧼 Prestige Unlimited = a clean car whenever you want, one flat monthly price.\n\nAsk our crew about the Unlimited plan on your next visit.\n📍 7601 Reseda Blvd · 📞 (747) 265-6293",
+ "hashtags": ["core", "deal"] },
+ { "id": "tpl-review-thanks", "name": "Thank-you / reviews", "theme": "Community",
+ "caption": "Thank you Reseda! 🙏 4.3⭐ and climbing — our crew reads every review.\n\nLoved your wash? Tag us @prestigecwash and drop a Google review. It means the world to a local shop.\n📍 7601 Reseda Blvd",
+ "hashtags": ["core", "community"] }
+ ]
+}
diff --git a/public/admin/index.html b/public/admin/index.html
index 3b05284..550a37d 100644
--- a/public/admin/index.html
+++ b/public/admin/index.html
@@ -24,6 +24,22 @@
.heat .d b{display:block;font-size:11px;color:var(--mut)}
code{background:var(--panel2);padding:1px 6px;border-radius:6px;font-size:12px}
.kv{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;font-size:14px}
+ /* Compose & Post */
+ .cmp{display:grid;grid-template-columns:1fr 340px;gap:16px;align-items:start}
+ @media(max-width:820px){.cmp{grid-template-columns:1fr}}
+ .cmp textarea{width:100%;min-height:150px;background:var(--panel);border:1px solid var(--line);color:var(--ink,#e9eef5);border-radius:12px;padding:12px;font:inherit;font-size:14px;line-height:1.5;resize:vertical}
+ .cmp .lab{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);margin:14px 0 6px}
+ .chips{display:flex;gap:6px;flex-wrap:wrap}
+ .chip{background:var(--panel);border:1px solid var(--line);color:var(--mut);border-radius:999px;padding:6px 11px;font-size:12px;font-weight:700;cursor:pointer;user-select:none}
+ .chip:hover{border-color:var(--brand);color:var(--ink,#e9eef5)}
+ .chip.on{background:linear-gradient(135deg,var(--brand),var(--brand2));color:#001018;border-color:transparent}
+ .assets{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;max-height:460px;overflow:auto;padding-right:4px}
+ .asset{position:relative;border:2px solid var(--line);border-radius:10px;overflow:hidden;cursor:pointer;aspect-ratio:1;background:var(--panel)}
+ .asset.on{border-color:var(--brand);box-shadow:0 0 0 2px rgba(10,132,255,.35)}
+ .asset img,.asset video{width:100%;height:100%;object-fit:cover;display:block}
+ .asset .k{position:absolute;top:4px;right:4px;font-size:10px;background:rgba(0,0,0,.6);color:#fff;border-radius:6px;padding:1px 5px;font-weight:700}
+ .cmpbtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
+ .hint{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 12px;font-size:12.5px;color:var(--mut);margin-top:10px}
</style>
</head>
<body>
@@ -50,7 +66,7 @@ function fmtDate(iso){ if(!iso) return '—'; const d=new Date(iso); if(isNaN(d)
return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
function when(iso){ return `<div class="when" title="created ${iso||''}">🕓 ${fmtDate(iso)}</div>`; }
const rating=r=>r?`<span class="rate">★ ${r}</span>`:'';
-const enc=s=>String(s==null?'':s).replace(/</g,'<');
+const enc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
let DENS=localStorage.getItem('pcwadmin.density')||'3';
$('#density').value=DENS; setDensity(DENS);
@@ -62,6 +78,7 @@ let t;$('#q').oninput=()=>{clearTimeout(t);t=setTimeout(render,200)};
// ---- tab definitions -------------------------------------------------------
const TABS=[
{id:'overview',label:'📊 Overview'},
+ {id:'compose',label:'✨ Compose & Post'},
{id:'competitors',label:'🥊 Competitors',api:'/api/admin/competitors',search:['name','city','notes','format'],
card:c=>cardWrap(`<div class="row"><div class="cat">${enc(c.city)} · ${enc(c.format)}</div>${rating(c.rating)}</div>
<div class="ttl">${enc(c.name)}</div>
@@ -108,6 +125,7 @@ const TABS=[
{id:'credentials',label:'🔑 Credentials'}
];
let CUR='overview';
+let CMP={sel:null,plats:new Set(),media:[],tpl:{platforms:[],hashtag_sets:{},templates:[]},bt:{},services:[]};
$('#tabs').innerHTML=TABS.map(t=>`<button data-id="${t.id}" class="${t.id===CUR?'on':''}" onclick="go('${t.id}')">${t.label}</button>`).join('');
window.go=id=>{CUR=id;document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.id===id));
@@ -120,6 +138,7 @@ function render(){
const tab=TABS.find(t=>t.id===CUR);
const showCtl=!!tab.api; $('#ctl').style.display=showCtl?'':'none';
if(tab.id==='overview') return renderOverview();
+ if(tab.id==='compose') return renderCompose();
if(tab.id==='best-post') return renderBestPost();
if(tab.id==='best-market') return renderBestMarket();
if(tab.id==='google') return renderGoogle();
@@ -135,20 +154,27 @@ function render(){
}
function renderOverview(){
- fetch('/api/health').then(r=>r.json()).then(h=>{
+ Promise.all([
+ fetch('/api/health').then(r=>r.json()),
+ fetch('/api/admin/credentials').then(r=>r.json()).catch(()=>[])
+ ]).then(([h,creds])=>{
const c=h.counts;
+ const places=(creds||[]).find(k=>k.key==='GOOGLE_PLACES_API_KEY');
+ const placesTag=places&&places.present
+ ? `<span class="tag b-good">Google Places: live ${enc(places.hint)}</span>`
+ : `<span class="tag b-mut">Google Places: key not set (mock)</span>`;
$('#view').innerHTML=`
<div class="stat">
${Object.entries(c).map(([k,v])=>`<div class="s"><b>${v}</b><span>${k}</span></div>`).join('')}
</div>
<div class="card"><div class="body">
<div class="ttl">Growth command center</div>
- <div class="blurb">Every bucket below is seeded from live SFV market research. Buckets with a search box are data-driven; the rest (best-times, Google, credentials) are tools. Cards show their created date + time. All write/spend/post actions are gated — see the plan.</div>
+ <div class="blurb">Every bucket below is seeded from live SFV market research. Buckets with a search box are data-driven; the rest (best-times, Google, credentials) are tools. Start in <b>✨ Compose & Post</b> to build a social post from your real photos/videos and open each network's composer in one click. Cards show their created date + time. All write/spend/post actions are gated.</div>
<div class="tags">
<span class="tag">Market: San Fernando Valley</span>
<span class="tag">IG: @prestigecwash</span>
<span class="tag">Media: Nano Banana + SeeDance</span>
- <span class="tag b-mut">Google Places: key not set (mock)</span>
+ ${placesTag}
</div>
</div></div>`;
$('#count').textContent='';
@@ -218,6 +244,118 @@ function renderCreds(){
$('#count').textContent='';
});
}
+// ---- Compose & Post: create socials + post from ONE place -----------------
+function renderCompose(){
+ $('#count').textContent='';
+ $('#view').innerHTML='<p style="color:var(--mut)">Loading assets…</p>';
+ Promise.all([
+ fetch('/api/admin/media').then(r=>r.json()).catch(()=>[]),
+ fetch('/api/admin/social/templates').then(r=>r.json()).catch(()=>({platforms:[],hashtag_sets:{},templates:[]})),
+ fetch('/api/admin/best-times').then(r=>r.json()).catch(()=>({})),
+ fetch('/api/admin/services').then(r=>r.json()).catch(()=>[])
+ ]).then(([media,tpl,bt,services])=>{
+ CMP.media=media; CMP.tpl=tpl; CMP.bt=bt; CMP.services=services;
+ paintCompose();
+ loadDrafts();
+ });
+}
+function paintCompose(){
+ const t=CMP.tpl, plats=t.platforms||[], sets=t.hashtag_sets||{}, tpls=t.templates||[];
+ const assetCard=m=>`<div class="asset ${CMP.sel&&CMP.sel.url===m.url?'on':''}" title="${enc(m.name)}"
+ onclick='cmpPick(${JSON.stringify({url:m.url,kind:m.kind,name:m.name,poster:m.poster||''})})'>
+ ${m.kind==='video'
+ ? `<video src="${enc(m.url)}" ${m.poster?`poster="${enc(m.poster)}"`:''} muted loop playsinline></video><span class="k">▶ video</span>`
+ : `<img src="${enc(m.url)}" loading="lazy" alt="${enc(m.name)}"><span class="k">photo</span>`}
+ </div>`;
+ $('#view').innerHTML=`
+ <div class="cmp">
+ <div>
+ <div class="lab">1 · Start from a template <span style="font-weight:600;text-transform:none;color:var(--mut)">(optional — fills the caption)</span></div>
+ <div class="chips" id="cmpTpls">${tpls.map(x=>`<span class="chip" onclick="cmpTpl('${x.id}')">${enc(x.name)} · <span style="color:var(--gold)">${enc(x.theme)}</span></span>`).join('')||'<span class="hint">No templates yet.</span>'}</div>
+ <div class="chips" style="margin-top:8px"><label style="font-size:12px;color:var(--mut)">Fill {service} with
+ <select id="cmpSvc" style="background:var(--panel);border:1px solid var(--line);color:inherit;border-radius:8px;padding:4px 8px;font:inherit">
+ <option value="">— pick a service —</option>
+ ${(CMP.services||[]).map(s=>`<option value="${enc(s.id)}">${enc(s.name)}</option>`).join('')}
+ </select></label></div>
+
+ <div class="lab">2 · Caption</div>
+ <textarea id="cmpCap" placeholder="Write your post… or pick a template above."></textarea>
+ <div style="font-size:11px;color:var(--mut);margin-top:4px"><span id="cmpLen">0</span> chars</div>
+
+ <div class="lab">Add hashtags</div>
+ <div class="chips">${Object.keys(sets).map(k=>`<span class="chip" onclick="cmpTags('${k}')">+ ${enc(k)} <span style="color:var(--mut)">(${sets[k].length})</span></span>`).join('')}</div>
+
+ <div class="lab">3 · Post to</div>
+ <div class="chips" id="cmpPlats">${plats.map(p=>`<span class="chip ${CMP.plats.has(p.id)?'on':''}" data-p="${p.id}" onclick="cmpPlat('${p.id}')">${p.emoji||'•'} ${enc(p.label)}</span>`).join('')}</div>
+ <div class="hint" id="cmpHint"></div>
+
+ <div class="cmpbtns">
+ <button class="btn" onclick="cmpCopy()">📋 Copy caption</button>
+ <button class="btn ghost" onclick="cmpDownload()">⬇ Download asset</button>
+ <button class="btn ghost" onclick="cmpSave()">💾 Save draft</button>
+ </div>
+ <div class="cmpbtns" id="cmpOpen"></div>
+ <div class="when" id="cmpMsg"></div>
+ </div>
+
+ <div>
+ <div class="lab" style="margin-top:0">Assets <span style="font-weight:600;text-transform:none;color:var(--mut)">(click to attach)</span></div>
+ <div class="assets">${CMP.media.map(assetCard).join('')||'<span class="hint">No media found in /media.</span>'}</div>
+ <div class="lab">Selected</div>
+ <div class="hint" id="cmpSel">${CMP.sel?enc(CMP.sel.name):'None — the post will be text-only.'}</div>
+ </div>
+ </div>
+ <div class="lab" style="margin-top:26px">📅 Saved drafts</div>
+ <div id="cmpDrafts"><span class="hint">Loading…</span></div>`;
+ const ta=$('#cmpCap'); ta.oninput=()=>{$('#cmpLen').textContent=ta.value.length;};
+ cmpSyncHint();
+}
+window.cmpPick=a=>{ CMP.sel=(CMP.sel&&CMP.sel.url===a.url)?null:a;
+ document.querySelectorAll('.asset').forEach(el=>el.classList.remove('on'));
+ if(CMP.sel){[...document.querySelectorAll('.asset')].find(el=>el.querySelector('[src="'+CSS.escape(CMP.sel.url)+'"]'))?.classList.add('on');}
+ $('#cmpSel').textContent=CMP.sel?CMP.sel.name:'None — the post will be text-only.'; };
+window.cmpTpl=id=>{ const x=(CMP.tpl.templates||[]).find(t=>t.id===id); if(!x)return;
+ const svc=(CMP.services||[]).find(s=>s.id===$('#cmpSvc').value)||{};
+ let cap=x.caption.replace(/\{service\}/g,svc.name||'our detailing').replace(/\{blurb\}/g,svc.blurb||'done right, by hand');
+ const tags=(x.hashtags||[]).flatMap(k=>CMP.tpl.hashtag_sets[k]||[]);
+ const ta=$('#cmpCap'); ta.value=cap+(tags.length?'\n\n'+tags.join(' '):''); $('#cmpLen').textContent=ta.value.length; };
+window.cmpTags=k=>{ const tags=(CMP.tpl.hashtag_sets[k]||[]); const ta=$('#cmpCap');
+ const have=ta.value; const add=tags.filter(t=>!have.includes(t)); if(!add.length)return;
+ ta.value=(have.trim()+' '+add.join(' ')).trim(); $('#cmpLen').textContent=ta.value.length; };
+window.cmpPlat=id=>{ CMP.plats.has(id)?CMP.plats.delete(id):CMP.plats.add(id);
+ document.querySelector(`#cmpPlats [data-p="${id}"]`)?.classList.toggle('on'); cmpSyncHint(); };
+function cmpSyncHint(){
+ const pt=(CMP.bt.post_times)||{}; const open=$('#cmpOpen'); const hint=$('#cmpHint');
+ const sel=[...CMP.plats]; const plats=CMP.tpl.platforms||[];
+ if(!sel.length){ hint.innerHTML='Pick one or more platforms — you\'ll get a one-click link into each native composer.'; open.innerHTML=''; return; }
+ const rows=sel.map(id=>{ const p=plats.find(x=>x.id===id)||{}; const key=Object.keys(pt).find(k=>k.toLowerCase().includes(id));
+ const times=key?pt[key]:null;
+ return `<div style="margin:4px 0">${p.emoji||'•'} <b>${enc(p.label||id)}</b>${times?` · ⏰ best: ${times.map(enc).join(', ')}`:''}<br><span style="color:var(--mut)">${enc(p.note||'')}</span></div>`; });
+ hint.innerHTML=rows.join('');
+ open.innerHTML=sel.map(id=>{ const p=plats.find(x=>x.id===id)||{}; return `<button class="btn ghost" onclick="cmpOpen('${enc(p.composeUrl||'')}')">↗ Open ${enc(p.label||id)}</button>`; }).join('');
+}
+window.cmpOpen=url=>{ if(url)window.open(url,'_blank','noopener'); const cap=$('#cmpCap').value;
+ if(cap&&navigator.clipboard){navigator.clipboard.writeText(cap).then(()=>{$('#cmpMsg').textContent='Caption copied — paste it into the composer that just opened.';});} };
+window.cmpCopy=()=>{ const cap=$('#cmpCap').value; if(!cap)return; navigator.clipboard?.writeText(cap)
+ .then(()=>$('#cmpMsg').textContent='📋 Caption copied to clipboard.'); };
+window.cmpDownload=()=>{ if(!CMP.sel){$('#cmpMsg').textContent='Pick an asset first.';return;}
+ const a=document.createElement('a'); a.href=CMP.sel.url; a.download=CMP.sel.name; document.body.appendChild(a); a.click(); a.remove();
+ $('#cmpMsg').textContent='⬇ Downloading '+CMP.sel.name+' — attach it in the composer.'; };
+window.cmpSave=()=>{ const caption=$('#cmpCap').value.trim();
+ if(!caption&&!CMP.sel){$('#cmpMsg').textContent='Add a caption or pick an asset first.';return;}
+ fetch('/api/admin/social/draft',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({caption,platforms:[...CMP.plats],asset:CMP.sel})})
+ .then(r=>r.json()).then(j=>{ $('#cmpMsg').textContent=j.ok?'💾 Draft saved.':(j.error||'Save failed'); if(j.ok)loadDrafts(); }); };
+function loadDrafts(){
+ fetch('/api/admin/social/drafts').then(r=>r.json()).then(list=>{
+ const el=$('#cmpDrafts'); if(!el)return;
+ el.innerHTML=list.length?`<div class="grid">${list.map(d=>cardWrap(
+ `<div class="row"><div class="cat">${(d.platforms||[]).map(enc).join(' · ')||'no platform'}</div><span class="badge b-warn">${enc(d.status)}</span></div>
+ ${d.asset?`<div class="tags"><span class="tag">📎 ${enc(d.asset.name)}</span></div>`:''}
+ <div class="blurb" style="white-space:pre-wrap">${enc((d.caption||'').slice(0,240))}${(d.caption||'').length>240?'…':''}</div>${when(d.created_at)}`
+ )).join('')}</div>`:'<span class="hint">No drafts yet — compose one above and hit Save draft.</span>';
+ });
+}
render();
</script>
</body>
diff --git a/server.js b/server.js
index 2946c06..13940ba 100644
--- a/server.js
+++ b/server.js
@@ -157,6 +157,65 @@ app.get('/api/admin/services', (req, res) => res.json(services()));
app.get('/api/admin/best-times', (req, res) => res.json(bestTimes()));
app.get('/api/admin/places', (req, res) => res.json(places()));
+// ---- Socials: create + post from ONE place (draft-only; never auto-posts) --
+// Media library: every image/video under /media (incl. media/real/), each video
+// paired with its poster image (svc-<stem>.png or <stem>-poster.jpg) so the
+// composer can preview + attach any asset. Read-only scan of the media dir.
+const MEDIA_DIR = path.join(__dirname, 'media');
+function mediaLibrary() {
+ const walk = (dir, prefix) => {
+ let out = [];
+ let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
+ for (const e of entries) {
+ if (e.isDirectory()) { out = out.concat(walk(path.join(dir, e.name), prefix + e.name + '/')); continue; }
+ const ext = path.extname(e.name).toLowerCase();
+ const kind = ['.mp4', '.mov', '.webm'].includes(ext) ? 'video'
+ : ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? 'image' : null;
+ if (!kind) continue;
+ out.push({ name: e.name, stem: e.name.replace(/\.[^.]+$/, ''), kind, url: '/media/' + prefix + e.name });
+ }
+ return out;
+ };
+ const all = walk(MEDIA_DIR, '');
+ const imgByStem = {};
+ all.filter(m => m.kind === 'image').forEach(m => { imgByStem[m.stem] = m.url; });
+ all.forEach(m => { if (m.kind === 'video') m.poster = imgByStem[m.stem] || imgByStem[m.stem + '-poster'] || ''; });
+ // Hide poster stills that only exist to back a video (keep genuine standalone photos).
+ const videoStems = new Set(all.filter(m => m.kind === 'video').map(m => m.stem));
+ return all
+ .filter(m => !(m.kind === 'image' && (videoStems.has(m.stem) || videoStems.has(m.stem.replace(/-poster$/, '')))))
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+app.get('/api/admin/media', (req, res) => res.json(mediaLibrary()));
+app.get('/api/admin/social/templates', (req, res) => res.json(socialTpl()));
+
+// Save a composed post as a DRAFT (append-only). This NEVER posts to any network —
+// the front-end deep-links into each platform's native composer for the human to post.
+app.post('/api/admin/social/draft', (req, res) => {
+ const b = req.body || {};
+ const draft = {
+ id: 'sd-' + Date.now().toString(36),
+ caption: cap(b.caption, 2200),
+ platforms: Array.isArray(b.platforms) ? b.platforms.slice(0, 12).map(p => cap(p, 40)) : [],
+ asset: b.asset ? { url: cap(b.asset.url, 300), kind: cap(b.asset.kind, 12), name: cap(b.asset.name, 160) } : null,
+ scheduled_for: cap(b.scheduled_for, 40),
+ created_at: new Date().toISOString(), status: 'draft'
+ };
+ if (!draft.caption && !draft.asset) return res.status(400).json({ ok: false, error: 'need a caption or an asset' });
+ try {
+ const dir = path.join(__dirname, 'reports');
+ fs.mkdirSync(dir, { recursive: true });
+ fs.appendFileSync(path.join(dir, 'social-drafts.jsonl'), JSON.stringify(draft) + '\n');
+ } catch { return res.status(500).json({ ok: false, error: 'could not save draft' }); }
+ res.json({ ok: true, draft });
+});
+app.get('/api/admin/social/drafts', (req, res) => {
+ try {
+ const raw = fs.readFileSync(path.join(__dirname, 'reports', 'social-drafts.jsonl'), 'utf8').trim();
+ res.json(raw ? raw.split('\n').map(l => JSON.parse(l)).reverse() : []);
+ } catch { res.json([]); }
+});
+
// Credentials tab — presence + last-4 only. NEVER returns secret values.
app.get('/api/admin/credentials', (req, res) => {
const keys = [
← 9002496 auto-save: 2026-07-19T07:25:16 (1 files) — server.js
·
back to Prestige Car Wash
·
Update Prestige business phone to (747) 265-6293 in places.j 62c7522 →