[object Object]

← 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

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,'&lt;');
+const enc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 →