← back to Dw Launches
command-center suite: view switcher (Dashboard/Compose/Streams/Calendar/Drafts/Approvals), density sliders, Hootsuite-style streams board, dashboard landing, approvals queue, launches open as modal-rig movable modals; preserves all compose logic + gates
a2037e74afdba3c1e3f303200fd669b72299f887 · 2026-06-19 10:34:28 -0700 · SteveStudio2
Files touched
Diff
commit a2037e74afdba3c1e3f303200fd669b72299f887
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Fri Jun 19 10:34:28 2026 -0700
command-center suite: view switcher (Dashboard/Compose/Streams/Calendar/Drafts/Approvals), density sliders, Hootsuite-style streams board, dashboard landing, approvals queue, launches open as modal-rig movable modals; preserves all compose logic + gates
---
public/index.html | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 423 insertions(+), 20 deletions(-)
diff --git a/public/index.html b/public/index.html
index 48d15b6..dba74e3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -631,6 +631,145 @@
top:72px;
align-self:flex-start;
}
+
+ /* ─── modal-rig theming (CSS vars it reads) ──────────────────── */
+ :root{ --card:var(--panel); --border:var(--line); }
+
+ /* ─── View switcher (Compose | Streams | Calendar …) ─────────── */
+ .topbar-right{display:flex;align-items:center;gap:18px}
+ .viewswitch{display:flex;gap:2px}
+ .viewswitch button{
+ background:transparent;border:none;color:var(--muted);
+ padding:8px 14px;border-radius:var(--radius-sm);cursor:pointer;
+ font-size:12.5px;font-family:var(--font);letter-spacing:.3px;
+ transition:color .15s, background .15s;
+ }
+ .viewswitch button:hover{color:var(--ink-2);background:var(--panel2)}
+ .viewswitch button.active{color:var(--ink);background:var(--panel2);box-shadow:inset 0 -2px 0 var(--gold)}
+ .viewswitch button .pill{
+ display:inline-block;min-width:16px;text-align:center;margin-left:6px;
+ font-size:9.5px;padding:1px 5px;border-radius:999px;
+ background:var(--gold-faint2);color:var(--gold);
+ }
+
+ /* ─── Density slider bar (above grids/streams) ───────────────── */
+ .density-bar{
+ display:flex;align-items:center;gap:12px;
+ max-width:1520px;margin:0 auto;padding:0 28px;
+ }
+ .density-bar .controls{
+ display:flex;align-items:center;gap:10px;margin-left:auto;
+ background:var(--panel2);border:1px solid var(--line);
+ border-radius:999px;padding:6px 14px;
+ }
+ .density-bar label{margin:0;text-transform:uppercase;font-size:10px;letter-spacing:.6px;color:var(--muted)}
+ .density-bar input[type=range]{
+ width:150px;-webkit-appearance:none;appearance:none;height:3px;
+ background:var(--line);border-radius:3px;cursor:pointer;padding:0;
+ }
+ .density-bar input[type=range]::-webkit-slider-thumb{
+ -webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;
+ background:var(--gold);cursor:pointer;border:2px solid var(--bg);
+ }
+ .density-bar .selwrap{display:flex;align-items:center;gap:8px}
+ .density-bar select{width:auto;min-width:130px;padding:6px 26px 6px 10px;font-size:12px}
+
+ /* ─── Dashboard ──────────────────────────────────────────────── */
+ .dash-wrap{max-width:1520px;margin:0 auto;padding:20px 28px}
+ .stat-grid{
+ display:grid;
+ grid-template-columns:repeat(auto-fill,minmax(var(--statmin,200px),1fr));
+ gap:14px;margin-bottom:24px;
+ }
+ .stat{
+ background:var(--panel);border:1px solid var(--line);
+ border-radius:var(--radius-lg);padding:18px 20px;
+ }
+ .stat .v{font-family:var(--serif);font-size:34px;line-height:1;color:var(--ink)}
+ .stat .v.gold{color:var(--gold)}
+ .stat .v.warn{color:var(--warn)}
+ .stat .v.ok{color:#5ab880}
+ .stat .v.info{color:#6aacda}
+ .stat .k{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-top:8px}
+ .dash-section-title{
+ font-family:var(--serif);font-size:15px;color:var(--ink);
+ margin:4px 0 14px;letter-spacing:.3px;
+ }
+ .chan-grid{
+ display:grid;
+ grid-template-columns:repeat(auto-fill,minmax(var(--chanmin,220px),1fr));
+ gap:12px;
+ }
+ .chan-tile{
+ background:var(--panel);border:1px solid var(--line);
+ border-radius:var(--radius);padding:14px 16px;
+ cursor:pointer;transition:border-color .15s, background .15s;
+ }
+ .chan-tile:hover{border-color:var(--gold-dim);background:var(--panel2)}
+ .chan-tile .ch-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
+ .chan-tile .av{width:9px;height:9px;border-radius:50%;flex-shrink:0}
+ .chan-tile .ch-name{font-size:13px;color:var(--ink)}
+ .chan-tile .ch-stats{display:flex;gap:14px;font-size:11px;color:var(--ink-2)}
+ .chan-tile .ch-stats b{color:var(--ink);font-size:14px;font-weight:500;display:block}
+ .chan-tile .ch-stats .lab{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}
+ .chan-tile.off{opacity:.5}
+
+ /* ─── Streams board (Hootsuite-style columns) ────────────────── */
+ .streams-board{
+ max-width:1520px;margin:0 auto;padding:16px 28px 40px;
+ display:grid;
+ grid-template-columns:repeat(var(--cols,4),minmax(0,1fr));
+ gap:14px;
+ align-items:start;
+ }
+ .stream-col{
+ background:var(--panel);border:1px solid var(--line);
+ border-radius:var(--radius-lg);overflow:hidden;min-width:0;
+ }
+ .stream-col .sc-head{
+ display:flex;align-items:center;gap:8px;
+ padding:12px 14px;border-bottom:1px solid var(--line-soft);
+ background:var(--panel2);position:sticky;top:0;
+ }
+ .stream-col .sc-head .av{width:9px;height:9px;border-radius:50%}
+ .stream-col .sc-head .nm{font-size:12.5px;color:var(--ink);font-family:var(--serif)}
+ .stream-col .sc-head .ct{margin-left:auto;font-size:10px;color:var(--muted)}
+ .stream-col .sc-head .off{font-size:9px;color:var(--warn);border:1px solid var(--warn-faint);background:var(--warn-faint);padding:1px 5px;border-radius:4px}
+ .stream-col .sc-body{padding:10px;display:flex;flex-direction:column;gap:14px}
+ .stream-seg .seg-label{
+ font-size:9.5px;text-transform:uppercase;letter-spacing:1px;
+ color:var(--gold-dim);margin-bottom:7px;display:flex;align-items:center;gap:6px;
+ }
+ .stream-seg .seg-label .n{color:var(--muted)}
+ .stream-seg .seg-empty{font-size:11px;color:var(--muted);font-style:italic;padding:2px 0 6px}
+ .stream-card{
+ background:var(--panel2);border:1px solid var(--line);
+ border-left:3px solid var(--gold);border-radius:var(--radius-sm);
+ padding:9px 11px;margin-bottom:7px;cursor:pointer;
+ transition:border-color .15s, background .15s;
+ }
+ .stream-card:hover{border-color:var(--gold-dim);background:var(--panel3)}
+ .stream-card .st-title{font-size:12px;color:var(--ink);font-weight:500;line-height:1.3}
+ .stream-card .st-body{font-size:11px;color:var(--ink-2);margin-top:4px;line-height:1.4;
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+ .stream-card .st-meta{display:flex;align-items:center;gap:6px;margin-top:7px;flex-wrap:wrap}
+ .stream-card .st-when{font-size:10px;color:var(--muted)}
+
+ /* ─── Approvals view ─────────────────────────────────────────── */
+ .approvals-wrap{max-width:1100px;margin:0 auto;padding:20px 28px}
+ .appr-card{
+ background:var(--panel);border:1px solid var(--line);
+ border-radius:var(--radius);padding:15px 17px;margin-bottom:10px;
+ display:flex;align-items:flex-start;gap:14px;
+ }
+ .appr-card.pending{border-color:rgba(184,146,58,.4)}
+ .appr-card .ac-main{flex:1;min-width:0}
+ .appr-card .ac-title{font-size:13.5px;color:var(--ink);font-weight:500}
+ .appr-card .ac-body{font-size:12px;color:var(--ink-2);margin-top:5px;line-height:1.45;
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+ .appr-card .ac-meta{font-size:11px;color:var(--muted);margin-top:7px;display:flex;gap:10px;flex-wrap:wrap;align-items:center}
+ .appr-card .ac-actions{display:flex;flex-direction:column;gap:7px;flex:0 0 auto;width:170px}
+ .appr-card .ac-actions button{width:100%}
</style>
</head>
<body>
@@ -639,13 +778,27 @@
<div class="brand-eyebrow">Launches · Social Composer</div>
<div class="brand-name">Designer Wallcoverings<span class="dot">.</span></div>
</div>
- <nav class="tabs">
- <button id="nav-compose" class="active" onclick="showView('compose')">Compose</button>
- <button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
- <button id="nav-drafts" onclick="showView('drafts')">Drafts & Queue</button>
- </nav>
+ <div class="topbar-right">
+ <nav class="viewswitch tabs" id="viewswitch">
+ <button id="nav-dashboard" onclick="showView('dashboard')">Dashboard</button>
+ <button id="nav-compose" class="active" onclick="showView('compose')">Compose</button>
+ <button id="nav-streams" onclick="showView('streams')">Streams</button>
+ <button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
+ <button id="nav-drafts" onclick="showView('drafts')">Drafts & Queue</button>
+ <button id="nav-approvals" onclick="showView('approvals')">Approvals<span class="pill hide" id="appr-pill">0</span></button>
+ </nav>
+ </div>
</header>
+<!-- ============ DASHBOARD VIEW ============ -->
+<div id="view-dashboard" class="hide">
+ <div class="dash-wrap">
+ <div class="stat-grid" id="dash-stats"></div>
+ <div class="dash-section-title">Per-channel</div>
+ <div class="chan-grid" id="dash-channels"></div>
+ </div>
+</div>
+
<!-- ============ COMPOSE VIEW ============ -->
<div id="view-compose">
<div class="wrap">
@@ -775,16 +928,43 @@
<!-- ============ DRAFTS VIEW ============ -->
<div id="view-drafts" class="hide">
- <div class="wrap"><div class="col">
- <div class="card">
- <h3>Drafts & Queue</h3>
- <div id="drafts-list"></div>
+ <div class="density-bar" style="padding-top:6px;padding-bottom:12px">
+ <span class="muted" style="font-size:12px">Every launch — click a card to open as a movable modal, or load into Compose</span>
+ <div class="controls">
+ <label for="drafts-density">Density</label>
+ <input type="range" id="drafts-density" min="200" max="460" step="20" value="300">
</div>
- </div></div>
+ </div>
+ <div class="dash-wrap" style="padding-top:0">
+ <div id="drafts-list" class="chan-grid" style="--chanmin:var(--draftmin,300px)"></div>
+ </div>
+</div>
+
+<!-- ============ STREAMS VIEW (Hootsuite-style columns) ============ -->
+<div id="view-streams" class="hide">
+ <div class="density-bar" style="padding-top:6px;padding-bottom:12px">
+ <span class="muted" style="font-size:12px">Channel streams — scheduled · pending · published · drafts</span>
+ <div class="controls">
+ <label for="streams-density">Columns</label>
+ <input type="range" id="streams-density" min="2" max="7" step="1" value="4">
+ <span class="muted" id="streams-density-val" style="font-size:11px;min-width:14px">4</span>
+ </div>
+ </div>
+ <div class="streams-board" id="streams-board"></div>
+</div>
+
+<!-- ============ APPROVALS VIEW ============ -->
+<div id="view-approvals" class="hide">
+ <div class="approvals-wrap">
+ <h3 style="font-family:var(--serif);font-size:15px;color:var(--ink);margin-bottom:6px;font-weight:400">Approvals queue</h3>
+ <p class="muted" style="margin-bottom:16px">Pending sign-off and approved-awaiting-publish. Nothing goes live without explicit Steve sign-off (STEVE-GO). Publish is simulated only.</p>
+ <div id="approvals-list"></div>
+ </div>
</div>
<div id="toast" class="toast hide"></div>
+<script src="/modal-rig.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' };
@@ -1106,13 +1286,19 @@ async function publish(){
}
// ---------- views ----------
+const VIEWS=['dashboard','compose','streams','calendar','drafts','approvals'];
function showView(v){
- ['compose','calendar','drafts'].forEach(x=>{
+ if(!VIEWS.includes(v)) v='compose';
+ VIEWS.forEach(x=>{
document.getElementById('view-'+x).classList.toggle('hide',x!==v);
document.getElementById('nav-'+x).classList.toggle('active',x===v);
});
+ try{ localStorage.setItem('dwlaunch:view', v); }catch(e){}
+ if(v==='dashboard') renderDashboard();
+ if(v==='streams') renderStreams();
if(v==='calendar') renderCalendar();
if(v==='drafts') renderDrafts();
+ if(v==='approvals') renderApprovals();
}
// 8. calendar
@@ -1139,30 +1325,247 @@ async function renderCalendar(){
}
}
-// drafts list — admin cards w/ created date+time
+const BADGE_MAP={draft:'b-draft',pending_approval:'b-pending',approved:'b-approved',published:'b-published'};
+function fmtWhen(iso){ if(!iso) return '—'; try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return '—'; } }
+
+// drafts list — admin cards w/ created date+time, open as movable modal
async function renderDrafts(){
const {launches}=await api('/api/launches');
const el=document.getElementById('drafts-list'); el.innerHTML = launches.length?'':'<span class="muted">No launches yet.</span>';
launches.forEach(l=>{
const d=document.createElement('div'); d.className='launchcard';
- const created=l.created_at?new Date(l.created_at):null;
- const whenStr=created?created.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
- const m={draft:'b-draft',pending_approval:'b-pending',approved:'b-approved',published:'b-published'};
- d.innerHTML=`<div style="display:flex;justify-content:space-between;align-items:flex-start">
+ const whenStr=fmtWhen(l.created_at);
+ d.innerHTML=`<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<strong>${esc(l.title)}</strong>
- <span class="badge ${m[l.status]||'b-draft'}">${(l.status||'draft').replace('_',' ')}</span></div>
+ <span class="badge ${BADGE_MAP[l.status]||'b-draft'}">${(l.status||'draft').replace('_',' ')}</span></div>
<div class="muted" style="margin-top:4px">${(l.channelsSelected||[]).map(chanName).join(' · ')||'no channels'}</div>
- <div class="when" title="${l.created_at||''}">🕓 ${whenStr}</div>`;
- d.onclick=()=>{ state=l; activeChanTab=null; activePvTab=null; showView('compose'); hydrate(); toast('Loaded: '+l.title); };
+ <div class="when" title="${esc(l.created_at||'')}">🕓 ${whenStr}</div>`;
+ d.onclick=()=>openLaunchModal(l.id);
el.appendChild(d);
});
}
+// ---------- dashboard ----------
+async function renderDashboard(){
+ const dash=await api('/api/dashboard');
+ const c=dash.counts;
+ const cards=[
+ {v:c.scheduledToday,k:'Scheduled today',cls:'gold'},
+ {v:c.pendingApproval,k:'Pending approval',cls:'warn'},
+ {v:c.publishedThisWeek,k:'Published this week',cls:'info'},
+ {v:c.approved,k:'Approved (awaiting publish)',cls:'ok'},
+ {v:c.drafts,k:'Drafts',cls:''},
+ {v:c.totalLaunches,k:'Total launches',cls:''}
+ ];
+ document.getElementById('dash-stats').innerHTML = cards.map(x=>
+ `<div class="stat"><div class="v ${x.cls}">${x.v}</div><div class="k">${x.k}</div></div>`).join('');
+ updateApprovalsPill(c.pendingApproval+c.approved);
+ const cg=document.getElementById('dash-channels'); cg.innerHTML='';
+ dash.perChannel.forEach(p=>{
+ const t=document.createElement('div'); t.className='chan-tile'+(p.connected?'':' off');
+ t.innerHTML=`<div class="ch-head"><span class="av" style="background:${COLORS[p.id]}"></span>
+ <span class="ch-name">${esc(p.name)}</span>
+ ${p.connected?'':'<span class="connect" style="margin-left:auto;font-size:9px;color:var(--warn)">not connected</span>'}</div>
+ <div class="ch-stats">
+ <div><b>${p.scheduled}</b><span class="lab">sched</span></div>
+ <div><b>${p.pending}</b><span class="lab">pending</span></div>
+ <div><b>${p.published}</b><span class="lab">live</span></div>
+ <div><b>${p.drafts}</b><span class="lab">draft</span></div>
+ </div>`;
+ t.onclick=()=>{ showView('streams'); };
+ cg.appendChild(t);
+ });
+}
+function updateApprovalsPill(n){
+ const p=document.getElementById('appr-pill');
+ if(n>0){ p.textContent=n; p.classList.remove('hide'); } else p.classList.add('hide');
+}
+
+// ---------- streams (Hootsuite-style columns) ----------
+const SEGS=[['scheduled','Scheduled'],['pending','Pending approval'],['published','Published'],['drafts','Drafts']];
+async function renderStreams(){
+ const {columns}=await api('/api/streams');
+ const board=document.getElementById('streams-board'); board.innerHTML='';
+ columns.forEach(col=>{
+ const total=col.scheduled.length+col.pending.length+col.published.length+col.drafts.length;
+ const c=document.createElement('div'); c.className='stream-col';
+ let head=`<div class="sc-head"><span class="av" style="background:${COLORS[col.channel]}"></span>
+ <span class="nm">${esc(col.name)}</span>
+ ${col.connected?'':'<span class="off">connect</span>'}
+ <span class="ct">${total}</span></div>`;
+ let segHtml='';
+ SEGS.forEach(([key,label])=>{
+ const items=col[key]||[];
+ segHtml+=`<div class="stream-seg"><div class="seg-label">${label} <span class="n">· ${items.length}</span></div>`;
+ if(!items.length){ segHtml+=`<div class="seg-empty">—</div>`; }
+ else segHtml+=items.map(it=>streamCard(it)).join('');
+ segHtml+=`</div>`;
+ });
+ c.innerHTML=head+`<div class="sc-body">${segHtml}</div>`;
+ board.appendChild(c);
+ });
+ // wire card clicks (delegation)
+ board.querySelectorAll('.stream-card').forEach(el=>{
+ el.onclick=()=>openLaunchModal(el.getAttribute('data-id'));
+ });
+}
+function streamCard(it){
+ const when = it.datetime ? new Date(it.datetime).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})
+ : (it.mode?it.mode:'no schedule');
+ return `<div class="stream-card" data-id="${esc(it.launchId)}" style="border-left-color:${COLORS[it.channel]||'var(--gold)'}"
+ title="created ${esc(it.created_at||'')}">
+ <div class="st-title">${esc(it.title)}</div>
+ ${it.body?`<div class="st-body">${esc(it.body)}</div>`:''}
+ <div class="st-meta">
+ <span class="badge ${BADGE_MAP[it.status]||'b-draft'}">${(it.status||'draft').replace('_',' ')}</span>
+ <span class="st-when">🕓 ${when}</span>
+ </div>
+ </div>`;
+}
+
+// ---------- approvals ----------
+async function renderApprovals(){
+ const {launches}=await api('/api/launches');
+ const queue=launches.filter(l=>l.status==='pending_approval'||l.status==='approved');
+ updateApprovalsPill(queue.length);
+ const el=document.getElementById('approvals-list');
+ el.innerHTML = queue.length?'':'<span class="muted">Nothing awaiting sign-off. Submit a launch from Compose to populate this queue.</span>';
+ queue.forEach(l=>{
+ const pending=l.status==='pending_approval';
+ const body=(l.global&&l.global.body)||'';
+ const d=document.createElement('div'); d.className='appr-card'+(pending?' pending':'');
+ d.innerHTML=`<div class="ac-main">
+ <div class="ac-title">${esc(l.title)}</div>
+ ${body?`<div class="ac-body">${esc(body)}</div>`:''}
+ <div class="ac-meta">
+ <span class="badge ${BADGE_MAP[l.status]}">${l.status.replace('_',' ')}</span>
+ <span>${(l.channelsSelected||[]).map(chanName).join(' · ')||'no channels'}</span>
+ <span title="${esc(l.created_at||'')}">🕓 ${fmtWhen(l.created_at)}</span>
+ ${l.approval&&l.approval.approved?`<span style="color:#5ab880">✓ ${esc(l.approval.by)} · ${fmtWhen(l.approval.at)}</span>`:''}
+ </div>
+ </div>
+ <div class="ac-actions">
+ <button class="btn ghost" data-act="open" data-id="${esc(l.id)}">Open</button>
+ ${pending?`<button class="btn btn-approve" data-act="approve" data-id="${esc(l.id)}">Steve sign-off ✓</button>`
+ :`<button class="btn btn-publish" data-act="publish" data-id="${esc(l.id)}">Publish (simulated)</button>`}
+ </div>`;
+ el.appendChild(d);
+ });
+ el.querySelectorAll('button[data-act]').forEach(b=>{
+ const id=b.getAttribute('data-id'), act=b.getAttribute('data-act');
+ b.onclick=async()=>{
+ if(act==='open') return openLaunchModal(id);
+ if(act==='approve'){ await approveById(id); renderApprovals(); }
+ if(act==='publish'){ await publishById(id); renderApprovals(); }
+ };
+ });
+}
+
+// ---------- id-based approval/publish (used by Approvals + modal) ----------
+async function approveById(id){
+ const token=prompt('Steve sign-off required to unlock publish. Enter sign-off token (STEVE-GO):');
+ if(token===null) return false;
+ try{
+ const j=await api(`/api/launches/${id}/approve`,{method:'POST',body:JSON.stringify({token,by:'Steve'})});
+ if(state&&state.id===id) state=j.launch;
+ toast('Signed off ✓ — publish unlocked (still simulated).'); return true;
+ }catch(e){ toast('Sign-off rejected: '+e.message,true); return false; }
+}
+async function publishById(id){
+ try{
+ const j=await api(`/api/launches/${id}/publish`,{method:'POST'});
+ if(state&&state.id===id) state=j.launch;
+ toast('SIMULATED publish — no live API called. '+(j.note||'')); return true;
+ }catch(e){ toast('Publish blocked: '+e.message,true); return false; }
+}
+
+// ---------- launch as movable modal (modal-rig) ----------
+async function openLaunchModal(id){
+ let l;
+ try{ l=(await api('/api/launches/'+id)).launch; }
+ catch(e){ toast('Could not load launch.',true); return; }
+ const approved=l.approval&&l.approval.approved;
+ const chans=(l.channelsSelected&&l.channelsSelected.length)?l.channelsSelected:Object.keys(l.channels||{});
+ const chanRows=chans.map(ch=>{
+ const c=(l.channels&&l.channels[ch])||{};
+ const cs=c.schedule||l.schedule||{};
+ const when=cs.datetime?new Date(cs.datetime).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):(cs.mode||'—');
+ const body=c.body||l.global?.body||'';
+ return `<div style="border:1px solid var(--line-soft);border-radius:6px;padding:10px 12px;margin-bottom:8px;background:var(--panel2)">
+ <div style="display:flex;align-items:center;gap:8px;margin-bottom:5px">
+ <span style="width:8px;height:8px;border-radius:50%;background:${COLORS[ch]}"></span>
+ <strong style="font-size:12px">${esc(chanName(ch))}</strong>
+ <span class="muted" style="margin-left:auto;font-size:10px">🕓 ${esc(when)}</span>
+ </div>
+ <div style="font-size:12px;color:var(--ink-2);white-space:pre-wrap;line-height:1.45">${esc(body.slice(0,400))}${body.length>400?'…':''}</div>
+ </div>`;
+ }).join('') || '<span class="muted">No channels selected.</span>';
+
+ const bodyHtml=`
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
+ <span class="badge ${BADGE_MAP[l.status]||'b-draft'}">${(l.status||'draft').replace('_',' ')}</span>
+ <span class="muted" style="font-size:11px" title="${esc(l.created_at||'')}">created 🕓 ${fmtWhen(l.created_at)}</span>
+ ${approved?`<span class="muted" style="font-size:11px;color:#5ab880">✓ signed off by ${esc(l.approval.by)}</span>`:''}
+ </div>
+ <div id="ml-sec-head" style="font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--gold-dim);margin-bottom:8px">Per-channel</div>
+ <div id="ml-sec-body">${chanRows}</div>
+ <div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
+ <button class="btn ghost" id="ml-edit">Load into Compose</button>
+ ${l.status==='draft'?`<button class="btn btn-submit" id="ml-submit">Submit for approval</button>`:''}
+ ${(l.status==='pending_approval')?`<button class="btn btn-approve" id="ml-approve">Steve sign-off ✓</button>`:''}
+ <button class="btn btn-publish" id="ml-publish" ${approved?'':'disabled'}>${approved?'Publish (simulated)':'Publish (gated)'}</button>
+ </div>
+ ${approved?'':'<div class="gatebar warn" style="margin-top:10px"><strong>Publish is gated.</strong><div class="muted">Requires explicit Steve sign-off (STEVE-GO). Simulated only — no live API.</div></div>'}`;
+
+ const m=ModalRig.open({ key:'launch-viewer', title:l.title||'Launch', bodyHtml, width:560, height:560, bg:'var(--panel)' });
+ const sh=m.body.querySelector('#ml-sec-head'), sb=m.body.querySelector('#ml-sec-body');
+ if(sh&&sb) ModalRig.section(sh, sb, 'launch-viewer:perchan');
+ const edit=m.body.querySelector('#ml-edit');
+ if(edit) edit.onclick=()=>{ state=l; activeChanTab=null; activePvTab=null; m.close(); showView('compose'); hydrate(); toast('Loaded: '+l.title); };
+ const sub=m.body.querySelector('#ml-submit');
+ if(sub) sub.onclick=async()=>{ try{ await api(`/api/launches/${id}/submit-approval`,{method:'POST'}); m.close(); toast('Submitted for approval.'); refreshActiveView(); }catch(e){ toast('Failed: '+e.message,true); } };
+ const appr=m.body.querySelector('#ml-approve');
+ if(appr) appr.onclick=async()=>{ if(await approveById(id)){ m.close(); refreshActiveView(); } };
+ const pub=m.body.querySelector('#ml-publish');
+ if(pub) pub.onclick=async()=>{ if(await publishById(id)){ m.close(); refreshActiveView(); } };
+}
+function refreshActiveView(){
+ const v=localStorage.getItem('dwlaunch:view')||'compose';
+ if(v==='dashboard')renderDashboard(); else if(v==='streams')renderStreams();
+ else if(v==='drafts')renderDrafts(); else if(v==='approvals')renderApprovals();
+ else if(v==='compose'){ renderState(); }
+}
+
+// ---------- density sliders (persisted) ----------
+function wireDensity(){
+ const sd=document.getElementById('streams-density'), sdv=document.getElementById('streams-density-val');
+ const savedCols=localStorage.getItem('dwlaunch:streamCols');
+ if(savedCols){ sd.value=savedCols; }
+ const applyCols=()=>{ document.getElementById('streams-board').style.setProperty('--cols',sd.value); sdv.textContent=sd.value; };
+ applyCols();
+ sd.addEventListener('input',()=>{ applyCols(); localStorage.setItem('dwlaunch:streamCols',sd.value); });
+
+ const dd=document.getElementById('drafts-density');
+ const savedDraft=localStorage.getItem('dwlaunch:draftMin');
+ if(savedDraft){ dd.value=savedDraft; }
+ const applyDraft=()=>{ document.documentElement.style.setProperty('--draftmin',dd.value+'px'); document.getElementById('drafts-list').style.setProperty('--chanmin',dd.value+'px'); };
+ applyDraft();
+ dd.addEventListener('input',()=>{ applyDraft(); localStorage.setItem('dwlaunch:draftMin',dd.value); });
+}
+
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); }
// wire global media drop
wireDrop('global-drop','global-file',()=>state.global.media);
-init();
+wireDensity();
+init().then(()=>{
+ // restore last-used view (persisted), default Compose
+ const saved=localStorage.getItem('dwlaunch:view');
+ if(saved && VIEWS.includes(saved) && saved!=='compose') showView(saved);
+ else { // refresh the approvals pill on load even when starting on Compose
+ api('/api/dashboard').then(d=>updateApprovalsPill((d.counts.pendingApproval||0)+(d.counts.approved||0))).catch(()=>{});
+ }
+});
</script>
</body>
</html>
← c49590a server: add /api/streams (per-channel segmented board) + /ap
·
back to Dw Launches
·
README: document command-center suite (views, density slider bd7e300 →