[object Object]

← back to Dw Launches

Streams → true Hootsuite model: user-addable columns (account × stream type incl. inbound mentions/comments/DMs/home/search), drag-reorder, add/remove, simulated inbound feed; plus 'all their items' pack — AI caption, hashtags, best-time, link-shorten, Bulk composer, simulated Analytics view

e5a2c9b61faacf002d0dde5f8f2a2bc8ede97149 · 2026-06-19 14:10:52 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit e5a2c9b61faacf002d0dde5f8f2a2bc8ede97149
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Jun 19 14:10:52 2026 -0700

    Streams → true Hootsuite model: user-addable columns (account × stream type incl. inbound mentions/comments/DMs/home/search), drag-reorder, add/remove, simulated inbound feed; plus 'all their items' pack — AI caption, hashtags, best-time, link-shorten, Bulk composer, simulated Analytics view
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/hoot-extras.js       | 107 ++++++++++++++++++++++++++++++++++++++++++++
 public/index.html           |   1 +
 screenshot-hoot-streams.png | Bin 0 -> 437901 bytes
 3 files changed, 108 insertions(+)

diff --git a/public/hoot-extras.js b/public/hoot-extras.js
new file mode 100644
index 0000000..be3990f
--- /dev/null
+++ b/public/hoot-extras.js
@@ -0,0 +1,107 @@
+'use strict';
+/* ====================================================================
+ * DW Launches — "all their items" feature pack (Hootsuite parity).
+ * Loaded as a separate classic script so it shares the page's global
+ * scope (can call state/api/toast/COLORS/chanName/esc/fmtShort/etc.)
+ * and exposes its functions as window globals for inline onclick=.
+ *
+ * Everything here is LOCAL: no paid API, no live social call.
+ *  - AI caption / hashtags / best-time / link-shorten  (Compose helpers)
+ *  - Bulk composer                                      (CSV → drafts)
+ *  - Analytics (simulated)                              ("Analyze" view)
+ * "Wallpaper" is the DW banned word — copy uses "Wallcoverings".
+ * ==================================================================== */
+
+// ---------- AI caption (OwlyWriter-style, fully local/templated $0) -------
+const DW_OPENERS  = ['Just landed','Now to the trade','Quiet luxury, delivered','New this week','Fresh on the loom','Straight from the archive'];
+const DW_SUBJECTS = ['hand-dyed grasscloth','heritage damask','natural cork','tonal raffia','metallic flocked surfaces','wide-width silk'];
+const DW_COLORS   = ['Oatmeal','Greige','Alabaster','Celadon','warm taupe','smoked clay'];
+const DW_BENEFITS = ['adds instant depth to a quiet room','reads beautifully on a feature wall','grounds a layered neutral scheme','brings texture without noise','elevates a corridor or entry','photographs like a dream on site'];
+function pickRand(a){ return a[Math.floor(Math.random()*a.length)]; }
+function capFirst(s){ return s.charAt(0).toUpperCase()+s.slice(1); }
+
+function aiCaption(){
+  const ta=document.getElementById('global-body');
+  if(ta.value.trim() && !confirm('Replace the current master body with a generated caption?')) return;
+  const cap=`${pickRand(DW_OPENERS)}: ${pickRand(DW_SUBJECTS)} in ${pickRand(DW_COLORS)}. ${capFirst(pickRand(DW_BENEFITS))} — to the trade now.\n\nMemo samples shipping this week. Designers, DM for trade pricing.`;
+  ta.value=cap; state.global.body=cap; renderCounter('global'); renderPreview();
+  toast('✨ Caption generated locally ($0). Edit freely before saving.');
+}
+function aiHashtags(){
+  const tags='#DesignerWallcoverings #ToTheTrade #InteriorDesign #LuxuryInteriors #Grasscloth #WallcoveringsNotWallpaper #DesignDetail';
+  const ta=document.getElementById('global-body');
+  if(ta.value.includes('#DesignerWallcoverings')){ toast('Hashtags already present.'); return; }
+  ta.value=(ta.value.trimEnd()+'\n'+tags); state.global.body=ta.value; renderCounter('global'); renderPreview();
+  toast('# DW hashtag set appended.');
+}
+function bestTime(){
+  const recs=[['Instagram','12:30 PM & 7:30 PM PT'],['Pinterest','8:00 PM PT'],['LinkedIn','9:00 AM Tue–Thu PT'],['TikTok','6:00 PM PT'],['Facebook','1:00 PM PT']];
+  toast('🕐 Best posting windows for a trade audience — see the popup.');
+  alert('Recommended posting windows (design-trade audience):\n\n'+recs.map(([c,t])=>`• ${c}: ${t}`).join('\n')+'\n\n(Heuristic, local — not from a live analytics API.)');
+}
+function shortenLinks(){
+  const ta=document.getElementById('global-body'); let n=0;
+  ta.value=ta.value.replace(/https?:\/\/[^\s]+/g,()=>{ n++; return 'dw.ly/'+Math.random().toString(36).slice(2,8); });
+  if(!n){ toast('No links found to shorten.'); return; }
+  state.global.body=ta.value; renderCounter('global'); renderPreview();
+  toast(`🔗 Shortened ${n} link${n>1?'s':''} to dw.ly (cosmetic stub — no live shortener).`);
+}
+
+// ---------- Bulk composer (CSV-ish lines → scheduled drafts) ---------------
+const BULK_CH={ig:'instagram',fb:'facebook',li:'linkedin',pin:'pinterest',tt:'tiktok',x:'twitter',tw:'twitter',yt:'youtube'};
+let bulkRows=[];
+function bulkParse(){
+  const lines=document.getElementById('bulk-input').value.split('\n').map(l=>l.trim()).filter(Boolean);
+  bulkRows=lines.map(line=>{
+    const parts=line.split('|').map(s=>s.trim());
+    if(parts.length<3) return { bad:true, why:'need date | channels | body', raw:line };
+    const [dt,chRaw,...rest]=parts; const body=rest.join(' | ');
+    let chans;
+    if(/^all$/i.test(chRaw)) chans=CHANNELS.filter(c=>c.connected).map(c=>c.id);
+    else chans=chRaw.split(',').map(s=>s.trim().toLowerCase()).map(k=>BULK_CH[k]||k).filter(k=>CHANNELS.some(c=>c.id===k));
+    const when=new Date(dt.replace(' ','T'));
+    const bad=!chans.length?'no valid channels':(isNaN(when)?'bad date':(/wallpaper/i.test(body)?'banned word "Wallpaper"':''));
+    return { bad:!!bad, why:bad, dt:isNaN(when)?null:when.toISOString(), chans, body };
+  });
+  document.getElementById('bulk-preview').innerHTML=bulkRows.map(r=>{
+    if(r.bad) return `<div class="bulk-row bad"><span style="color:var(--danger)">✕ ${esc(r.why)}</span><span class="br-body">${esc(r.raw||r.body||'')}</span></div>`;
+    const dots=r.chans.map(c=>`<span class="dot" style="background:${COLORS[c]}" title="${chanName(c)}"></span>`).join('');
+    return `<div class="bulk-row"><span class="br-ch">${dots}</span><span class="br-body">${esc(r.body)}</span><span class="br-when">🕓 ${fmtShort(r.dt)}</span></div>`;
+  }).join('')||'<span class="muted">Nothing parsed.</span>';
+  const ok=bulkRows.filter(r=>!r.bad).length;
+  const btn=document.getElementById('bulk-create');
+  btn.style.display=ok?'inline-block':'none'; btn.textContent=`Create ${ok} draft${ok>1?'s':''} ↓`;
+}
+async function bulkCreate(){
+  const good=bulkRows.filter(r=>!r.bad); let ok=0, fail=0;
+  for(const r of good){
+    const chans={}; r.chans.forEach(ch=>{ chans[ch]={body:'',media:[],fields:{},schedule:{mode:'schedule',datetime:r.dt}}; });
+    const launch={ id:null, title:r.body.slice(0,60), channelsSelected:r.chans, global:{body:r.body,media:[]},
+      channels:chans, schedule:{mode:'schedule',datetime:r.dt}, status:'draft', approval:{approved:false,by:null,at:null}, published:false };
+    try{ await api('/api/launches',{method:'POST',body:JSON.stringify(launch)}); ok++; }
+    catch(e){ fail++; }
+  }
+  toast(`Bulk: created ${ok} draft${ok!==1?'s':''}${fail?`, ${fail} failed (banned word?)`:''}. None published — all drafts.`);
+  document.getElementById('bulk-input').value=''; document.getElementById('bulk-preview').innerHTML=''; document.getElementById('bulk-create').style.display='none';
+}
+
+// ---------- Analytics (SIMULATED — Hootsuite "Analyze" equivalent) --------
+async function renderAnalytics(){
+  const a=await api('/api/analytics');
+  const fmt=n=>n>=1000?(n/1000).toFixed(n>=10000?0:1)+'k':String(n);
+  document.getElementById('ana-stats').innerHTML=[
+    {v:fmt(a.totals.followers),k:'Total audience',cls:'gold'},
+    {v:fmt(a.totals.reach),k:'Reach (7d)',cls:'info'},
+    {v:fmt(a.totals.engagement),k:'Engagements (7d)',cls:'ok'},
+    {v:a.perChannel.filter(c=>c.connected).length+'/'+a.perChannel.length,k:'Accounts connected',cls:''}
+  ].map(x=>`<div class="stat"><div class="v ${x.cls}">${x.v}</div><div class="k">${x.k}</div></div>`).join('');
+  const max=Math.max(...a.series.map(s=>s.reach))||1;
+  document.getElementById('ana-bars').innerHTML=a.series.map(s=>
+    `<div class="bar" style="height:${Math.round(s.reach/max*100)}%" title="${s.reach.toLocaleString()} reach"><span class="bl">${s.day}</span></div>`).join('');
+  document.getElementById('ana-table').innerHTML=
+    `<thead><tr><th>Account</th><th>Followers</th><th>Reach (7d)</th><th>Engagements</th><th>Eng. rate</th><th>Best time</th><th>Live posts</th></tr></thead><tbody>`+
+    a.perChannel.map(c=>`<tr>
+      <td><span class="av" style="background:${COLORS[c.id]}"></span><b>${esc(c.name)}</b>${c.connected?'':' <span class="muted">· off</span>'}</td>
+      <td>${c.followers.toLocaleString()}</td><td>${c.reach.toLocaleString()}</td><td>${c.engagement.toLocaleString()}</td>
+      <td>${c.engagementRate}%</td><td>${esc(c.bestTime)}</td><td>${c.postsLive}</td></tr>`).join('')+`</tbody>`;
+}
diff --git a/public/index.html b/public/index.html
index 4ad12d9..b8f922d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1178,6 +1178,7 @@
 <div id="toast" class="toast hide"></div>
 
 <script src="/modal-rig.js"></script>
+<script src="/hoot-extras.js"></script>
 <script>
 const CHAR_LIMITS = { instagram:2200, facebook:63206, linkedin:3000, pinterest:500, tiktok:2200, twitter:280, youtube:5000 };
 const COLORS = { instagram:'#E1306C', facebook:'#1877F2', linkedin:'#0A66C2', pinterest:'#E60023', tiktok:'#25F4EE', twitter:'#1DA1F2', youtube:'#FF0000' };
diff --git a/screenshot-hoot-streams.png b/screenshot-hoot-streams.png
new file mode 100644
index 0000000..0f11373
Binary files /dev/null and b/screenshot-hoot-streams.png differ

← 4a5e3f7 Media Library: attach real assets (Shopify 139k + our IG + v  ·  back to Dw Launches  ·  Add unified Inbox (Hootsuite parity): triage all mentions/co b51421c →