← back to Dw Launches
public/index.html
2203 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DW Launches — Social Composer</title>
<style>
/* ─── Design tokens ─────────────────────────────────────────── */
:root{
/* surfaces */
--bg:#0a0b0d;
--panel:#111318;
--panel2:#181b22;
--panel3:#1e222b;
--line:#252932;
--line-soft:#1e2129;
/* ink */
--ink:#e8e4dc;
--ink-2:#b0aaa0;
--muted:#6e7480;
/* brand palette */
--gold:#c2a064;
--gold-dim:#8a6e3e;
--gold-faint:rgba(194,160,100,.08);
--gold-faint2:rgba(194,160,100,.14);
/* status */
--danger:#c0505e;
--danger-faint:rgba(192,80,94,.12);
--warn:#b8923a;
--warn-faint:rgba(184,146,58,.12);
--ok:#3a8a60;
--ok-faint:rgba(58,138,96,.12);
--info:#3a6aa0;
--info-faint:rgba(58,106,160,.12);
/* interaction */
--focus:rgba(194,160,100,.35);
--radius:10px;
--radius-sm:6px;
--radius-lg:14px;
--font:"Helvetica Neue",Helvetica,Arial,sans-serif;
--serif:"Georgia","Times New Roman",serif;
/* spacing scale */
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:20px;
--sp-6:24px; --sp-7:28px; --sp-8:32px;
}
/* ─── Reset ─────────────────────────────────────────────────── */
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg);
color:var(--ink);
font-family:var(--font);
font-size:13.5px;
line-height:1.5;
-webkit-font-smoothing:antialiased;
}
/* ─── Topbar ─────────────────────────────────────────────────── */
header.topbar{
display:flex;
align-items:center;
justify-content:space-between;
padding:0 28px;
height:56px;
border-bottom:1px solid var(--line);
background:var(--panel);
position:sticky;
top:0;
z-index:20;
}
/* Wordmark */
.brand{
display:flex;
flex-direction:column;
gap:0;
line-height:1;
}
.brand-eyebrow{
font-family:var(--font);
font-size:9.5px;
letter-spacing:3px;
text-transform:uppercase;
color:var(--muted);
margin-bottom:3px;
}
.brand-name{
font-family:var(--serif);
font-size:22px;
font-weight:400;
letter-spacing:.8px;
color:var(--ink);
line-height:1;
}
.brand-name .dot{color:var(--gold)}
/* Topbar nav */
nav.tabs{display:flex;gap:2px}
nav.tabs button{
background:transparent;
border:none;
color:var(--muted);
padding:8px 16px;
border-radius:var(--radius-sm);
cursor:pointer;
font-size:12.5px;
font-family:var(--font);
letter-spacing:.3px;
transition:color .15s, background .15s;
}
nav.tabs button:hover{color:var(--ink-2);background:var(--panel2)}
nav.tabs button.active{
color:var(--ink);
background:var(--panel2);
box-shadow:inset 0 -2px 0 var(--gold);
}
/* ─── Layout ─────────────────────────────────────────────────── */
.wrap{
display:flex;
gap:20px;
padding:20px 28px;
max-width:1520px;
margin:0 auto;
}
.col{flex:1;min-width:0}
/* ─── Cards ─────────────────────────────────────────────────── */
.card{
background:var(--panel);
border:1px solid var(--line);
border-radius:var(--radius-lg);
padding:20px;
margin-bottom:14px;
}
/* Section label — editorial, not bureaucratic */
.section-label{
display:flex;
align-items:baseline;
gap:10px;
margin-bottom:16px;
padding-bottom:12px;
border-bottom:1px solid var(--line-soft);
}
.section-label .sl-step{
font-family:var(--font);
font-size:10px;
font-weight:600;
letter-spacing:1.5px;
text-transform:uppercase;
color:var(--gold-dim);
min-width:16px;
}
.section-label .sl-title{
font-family:var(--serif);
font-size:14px;
font-weight:400;
letter-spacing:.3px;
color:var(--ink);
}
/* Legacy h3 inside .card — keep for any missed cards */
.card h3{
font-family:var(--font);
font-size:10px;
letter-spacing:1.5px;
text-transform:uppercase;
color:var(--gold-dim);
margin:0 0 14px;
font-weight:600;
}
/* ─── Channel rail ───────────────────────────────────────────── */
.chanrail{display:flex;gap:8px;flex-wrap:wrap}
.chip{
display:flex;
align-items:center;
gap:8px;
padding:7px 13px 7px 9px;
border:1px solid var(--line);
border-radius:999px;
cursor:pointer;
user-select:none;
background:var(--panel2);
color:var(--ink-2);
font-size:12.5px;
transition:border-color .15s, color .15s, background .15s;
}
.chip:hover:not(.disabled){
border-color:var(--gold-dim);
color:var(--ink);
background:var(--panel3);
}
/* Brand-color dot replaces full candy avatar */
.chip .av{
width:8px;
height:8px;
border-radius:50%;
flex-shrink:0;
}
.chip.sel{
border-color:var(--gold);
color:var(--ink);
background:var(--gold-faint2);
}
.chip.sel .av{
/* Slightly brightened dot on selected */
box-shadow:0 0 0 2px rgba(0,0,0,.4);
}
.chip.disabled{opacity:.35;cursor:not-allowed}
.chip .connect{
font-size:9.5px;
color:var(--warn);
border:1px solid var(--warn-faint);
border-radius:4px;
padding:1px 5px;
background:var(--warn-faint);
}
/* ─── Form elements ──────────────────────────────────────────── */
textarea,
input[type=text],
input[type=datetime-local],
select{
width:100%;
background:var(--panel2);
border:1px solid var(--line);
color:var(--ink);
border-radius:var(--radius-sm);
padding:9px 11px;
font-family:inherit;
font-size:13px;
line-height:1.5;
transition:border-color .15s, box-shadow .15s;
-webkit-appearance:none;
appearance:none;
}
textarea:focus,
input[type=text]:focus,
input[type=datetime-local]:focus,
select:focus{
outline:none;
border-color:var(--gold-dim);
box-shadow:0 0 0 3px var(--focus);
}
textarea{min-height:128px;resize:vertical;line-height:1.6}
select{cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236e7480'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;padding-right:28px}
label{
display:block;
font-size:11px;
color:var(--muted);
margin:12px 0 4px;
letter-spacing:.4px;
text-transform:uppercase;
}
label:first-child{margin-top:0}
.row{display:flex;gap:10px}
.row>*{flex:1}
/* ─── Buttons ────────────────────────────────────────────────── */
button.btn{
border:none;
border-radius:var(--radius-sm);
padding:9px 18px;
font-weight:500;
cursor:pointer;
font-size:13px;
font-family:inherit;
letter-spacing:.2px;
transition:opacity .15s, background .15s, box-shadow .15s;
}
/* Primary — gold CTA (Save draft) */
button.btn:not(.ghost):not(.danger):not(.btn-submit):not(.btn-approve):not(.btn-publish){
background:var(--gold-faint2);
color:var(--gold);
border:1px solid var(--gold-dim);
}
button.btn:not(.ghost):not(.danger):not(.btn-submit):not(.btn-approve):not(.btn-publish):hover{
background:rgba(194,160,100,.22);
}
/* Ghost */
button.ghost{
background:transparent;
border:1px solid var(--line);
color:var(--ink-2);
}
button.ghost:hover{border-color:var(--ink-2);color:var(--ink)}
/* Submit for approval */
button.btn-submit{
background:var(--panel2);
border:1px solid var(--gold-dim);
color:var(--gold);
}
button.btn-submit:hover{background:var(--gold-faint2)}
/* Steve sign-off */
button.btn-approve{
background:var(--panel2);
border:1px solid var(--line);
color:var(--ink-2);
font-style:italic;
}
button.btn-approve:hover{border-color:var(--gold-dim);color:var(--ink)}
/* Publish — solid gold when enabled, visually suppressed when gated */
button.btn-publish{
background:var(--gold);
color:#0d0b06;
font-weight:600;
letter-spacing:.3px;
}
button.btn-publish:hover:not(:disabled){
background:#d4b070;
box-shadow:0 2px 12px rgba(194,160,100,.25);
}
button.btn-publish:disabled{
background:var(--panel3);
border:1px solid var(--line);
color:var(--muted);
cursor:not-allowed;
font-weight:400;
letter-spacing:0;
}
button.danger{background:var(--danger);color:#fff;border:none}
button.danger:hover{opacity:.88}
button:disabled{cursor:not-allowed}
/* ─── Per-channel tabs ───────────────────────────────────────── */
.ctabs{
display:flex;
gap:3px;
flex-wrap:wrap;
margin-bottom:14px;
padding-bottom:12px;
border-bottom:1px solid var(--line-soft);
}
.ctabs button{
padding:5px 12px;
font-size:12px;
border-radius:var(--radius-sm);
border:1px solid transparent;
background:transparent;
color:var(--muted);
cursor:pointer;
font-family:inherit;
transition:color .15s, background .15s, border-color .15s;
}
.ctabs button:hover{color:var(--ink-2);background:var(--panel2)}
.ctabs button.active{
color:var(--ink);
background:var(--panel2);
border-color:var(--line);
box-shadow:inset 0 -2px 0 var(--gold);
}
/* ─── Media ──────────────────────────────────────────────────── */
.media-drop{
border:1px dashed var(--line);
border-radius:var(--radius);
padding:18px 14px;
text-align:center;
color:var(--muted);
font-size:12px;
cursor:pointer;
transition:border-color .15s, color .15s, background .15s;
margin-top:4px;
}
.media-drop:hover{border-color:var(--gold-dim);color:var(--ink-2);background:var(--gold-faint)}
.media-drop.over{border-color:var(--gold);color:var(--ink);background:var(--gold-faint2)}
.media-list{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.media-item{
position:relative;
width:76px;
height:76px;
border-radius:var(--radius-sm);
background:var(--panel2);
border:1px solid var(--line);
display:flex;
align-items:center;
justify-content:center;
font-size:9.5px;
color:var(--muted);
overflow:hidden;
text-align:center;
padding:4px;
transition:border-color .15s;
}
.media-item:hover{border-color:var(--gold-dim)}
.media-item img{width:100%;height:100%;object-fit:cover;border-radius:3px}
.media-item .x{
position:absolute;
top:3px;right:4px;
cursor:pointer;
color:var(--danger);
font-weight:700;
font-size:13px;
line-height:1;
opacity:0;
transition:opacity .12s;
}
.media-item:hover .x{opacity:1}
.media-item .tag{
position:absolute;
bottom:3px;left:3px;
font-size:8px;
background:rgba(0,0,0,.65);
padding:1px 4px;
border-radius:3px;
letter-spacing:.3px;
text-transform:uppercase;
}
/* ─── Media Library view ─────────────────────────────────────── */
.ml-toolbar{display:flex;align-items:center;gap:14px;padding:14px 22px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.ml-sources{display:flex;gap:2px;flex-wrap:wrap}
.ml-sources button{
background:none;border:1px solid transparent;color:var(--ink-3);
font:inherit;font-size:13px;padding:5px 12px;border-radius:7px;cursor:pointer;
}
.ml-sources button:hover{color:var(--ink-2);background:var(--panel2)}
.ml-sources button.active{color:var(--ink);background:var(--gold-faint);border-color:var(--gold-dim)}
.ml-sources button .ct{opacity:.6;font-size:11px;margin-left:5px}
.ml-search{
background:var(--panel2);border:1px solid var(--line);border-radius:8px;
color:var(--ink);font:inherit;font-size:13px;padding:7px 12px;min-width:240px;
}
.ml-search:focus{outline:none;border-color:var(--gold-dim)}
.ml-note{padding:10px 22px;font-size:12px}
.ml-grid{
display:grid;gap:12px;padding:6px 22px 22px;
grid-template-columns:repeat(auto-fill,minmax(var(--mlmin,150px),1fr));
}
.ml-card{
position:relative;border:1px solid var(--line);border-radius:9px;overflow:hidden;
background:var(--panel2);display:flex;flex-direction:column;
}
.ml-card .thumb{width:100%;aspect-ratio:1/1;object-fit:cover;background:#1a1a1a;display:block}
.ml-card .meta{padding:7px 8px 8px;display:flex;flex-direction:column;gap:2px;min-height:0}
.ml-card .mt{font-size:11px;color:var(--ink);line-height:1.3;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.ml-card .mv{font-size:9px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.4px}
.ml-card .cw-chip{font-size:9px;color:#c2a064;background:var(--gold-faint);border:1px solid var(--gold-dim);
border-radius:4px;padding:1px 5px;margin-top:2px;align-self:flex-start;cursor:help;letter-spacing:.3px}
.ml-card .src{position:absolute;top:5px;left:5px;font-size:8px;background:rgba(0,0,0,.7);
color:#e9d9a8;padding:1px 6px;border-radius:4px;text-transform:uppercase;letter-spacing:.4px}
.ml-card .ml-acts{display:flex;border-top:1px solid var(--line)}
.ml-card .ml-acts button{
flex:1;border:none;background:var(--gold-faint);
color:var(--gold);font:inherit;font-size:11px;padding:6px 4px;cursor:pointer;letter-spacing:.2px;
}
.ml-card .ml-acts button+button{border-left:1px solid var(--line)}
.ml-card .ml-acts button:hover{background:var(--gold-faint2);color:var(--ink)}
.ml-card .att.added{background:#1d3a1d;color:#9fe09f}
.ml-card .rep{color:var(--ink-2)!important}
.ml-card .rep:hover{color:var(--ink)!important}
.ml-more{display:flex;align-items:center;gap:14px;padding:0 22px 30px}
.ml-empty{grid-column:1/-1;text-align:center;padding:48px 20px;color:var(--ink-2);line-height:1.7;font-size:14px}
/* ─── Counters ───────────────────────────────────────────────── */
.counter{font-size:11px;color:var(--muted);text-align:right;margin-top:3px}
.counter.over{color:var(--danger);font-weight:600}
/* ─── Preview panel ──────────────────────────────────────────── */
/* Outer wrapper — device chrome feel */
.preview-device{
background:var(--panel2);
border:1px solid var(--line);
border-radius:var(--radius-lg);
padding:14px;
margin-top:0;
}
.pv{
background:#fff;
color:#111;
border-radius:10px;
overflow:hidden;
box-shadow:0 1px 4px rgba(0,0,0,.18);
}
.pv .pvhead{display:flex;align-items:center;gap:9px;padding:10px 13px}
.pv .pvav{width:34px;height:34px;border-radius:50%;flex-shrink:0}
.pv .pvname{font-weight:700;font-size:13px;color:#111;line-height:1.2}
.pv .pvsub{font-size:11px;color:#888;margin-top:1px}
.pv .pvbody{
padding:2px 13px 11px;
font-size:13px;
line-height:1.5;
white-space:pre-wrap;
color:#1a1a1a;
}
.pv .pvimg{
width:100%;
background:#edeae4;
display:flex;
align-items:center;
justify-content:center;
color:#bbb;
font-size:11px;
letter-spacing:.5px;
text-transform:uppercase;
}
.pv.ig .pvimg{aspect-ratio:1/1}
.pv.pinterest .pvimg{aspect-ratio:2/3}
.pv.youtube .pvimg{aspect-ratio:16/9}
.pv.facebook .pvimg{aspect-ratio:1.91/1}
.pv.linkedin .pvimg{aspect-ratio:1.91/1}
.pv .pvactions{
display:flex;
gap:18px;
padding:9px 13px;
color:#777;
font-size:12px;
border-top:1px solid #f0ede8;
}
.pv .pvtitle{font-weight:700;font-size:14px;padding:9px 13px 3px;color:#111}
.pv .thread .tw{border-bottom:1px solid #f0ede8;padding:8px 0}
/* ─── Badges ─────────────────────────────────────────────────── */
.badge{
display:inline-block;
font-size:9.5px;
padding:2px 8px;
border-radius:999px;
letter-spacing:.6px;
text-transform:uppercase;
font-weight:500;
}
.b-draft{background:#252830;color:#7a8090;border:1px solid #2e3240}
.b-pending{background:var(--warn-faint);color:var(--warn);border:1px solid rgba(184,146,58,.25)}
.b-approved{background:var(--ok-faint);color:#5ab880;border:1px solid rgba(58,138,96,.25)}
.b-published{background:var(--info-faint);color:#6aacda;border:1px solid rgba(58,106,160,.25)}
/* ─── Gate bar ───────────────────────────────────────────────── */
.gatebar{
background:var(--panel2);
border:1px solid var(--line);
border-radius:var(--radius);
padding:12px 14px;
margin-top:10px;
font-size:12.5px;
}
.gatebar.warn{border-color:rgba(184,146,58,.4);background:var(--warn-faint)}
.gatebar strong{font-size:12.5px;color:var(--ink-2)}
.gatebar .muted{margin-top:4px}
/* Approval row — stack buttons cleanly */
.approval-actions{display:flex;flex-direction:column;gap:8px;margin-top:14px}
.approval-actions .row{gap:8px}
.approval-actions .row>*{flex:1}
/* ─── Misc helpers ───────────────────────────────────────────── */
.muted{color:var(--muted);font-size:12px}
.hide{display:none!important}
.divider{height:1px;background:var(--line-soft);margin:14px 0}
/* ─── Queue slots ────────────────────────────────────────────── */
.queueslots{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
.queueslots button{
font-size:11px;
padding:4px 10px;
border-radius:var(--radius-sm);
background:var(--panel2);
border:1px solid var(--line);
color:var(--ink-2);
cursor:pointer;
font-family:inherit;
transition:border-color .12s, color .12s;
}
.queueslots button:hover{border-color:var(--gold-dim);color:var(--ink)}
/* ─── Field block (platform fields) ─────────────────────────── */
.field-block{
border:1px solid var(--line-soft);
border-radius:var(--radius-sm);
padding:12px;
margin-top:10px;
background:var(--panel2);
}
/* ─── Calendar ───────────────────────────────────────────────── */
.cal{display:grid;grid-template-columns:repeat(7,1fr);gap:5px}
.cal .dh{
font-size:10.5px;
color:var(--muted);
text-align:center;
padding:5px 2px;
letter-spacing:.5px;
text-transform:uppercase;
}
.cal .cell{
min-height:76px;
background:var(--panel2);
border:1px solid var(--line);
border-radius:var(--radius-sm);
padding:6px;
font-size:11px;
}
.cal .cell .dn{color:var(--muted);font-size:10px;margin-bottom:2px}
.cal .ev{
font-size:10px;
margin-top:3px;
padding:2px 5px;
border-radius:4px;
background:var(--panel3);
border-left:2px solid var(--gold);
overflow:hidden;
white-space:nowrap;
text-overflow:ellipsis;
color:var(--ink-2);
}
#cal-label{
text-align:center;
font-family:var(--serif);
font-size:17px;
color:var(--ink);
letter-spacing:.3px;
}
/* ─── Launch cards (Drafts view) ─────────────────────────────── */
.launchcard{
background:var(--panel2);
border:1px solid var(--line);
border-radius:var(--radius);
padding:13px 15px;
margin-bottom:8px;
cursor:pointer;
transition:border-color .15s, background .15s;
}
.launchcard:hover{border-color:var(--gold-dim);background:var(--panel3)}
.launchcard .when{
font-size:11px;
color:var(--muted);
margin-top:5px;
display:flex;
align-items:center;
gap:4px;
}
/* ─── Toast ──────────────────────────────────────────────────── */
.toast{
position:fixed;
bottom:20px;
left:50%;
transform:translateX(-50%);
background:var(--panel2);
border:1px solid var(--gold-dim);
padding:11px 18px;
border-radius:var(--radius);
z-index:50;
max-width:500px;
font-size:13px;
color:var(--ink-2);
box-shadow:0 4px 24px rgba(0,0,0,.4);
animation:toastin .18s ease;
}
.toast.err{border-color:var(--danger);color:var(--danger)}
@keyframes toastin{from{opacity:0;transform:translateX(-50%) translateY(8px)}to{opacity:1;transform:translateX(-50%) translateY(0)}}
/* ─── State line ─────────────────────────────────────────────── */
#state-line{margin-bottom:6px;display:flex;align-items:center;gap:8px}
/* ─── Preview sticky wrapper ─────────────────────────────────── */
.preview-col{
flex:1;
min-width:0;
position:sticky;
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%}
/* ─── Hootsuite-style horizontal streams board ───────────────── */
.hoot-toolbar{
display:flex;align-items:center;gap:12px;
max-width:100%;margin:0;padding:10px 28px;border-bottom:1px solid var(--line-soft);
position:sticky;top:56px;background:var(--bg);z-index:10;
}
.hoot-toolbar .ht-title{font-size:12px;color:var(--muted)}
.hoot-toolbar .ht-spacer{margin-left:auto}
.hoot-toolbar button.ht-add{
background:var(--gold-faint2);border:1px solid var(--gold-dim);color:var(--gold);
padding:7px 14px;border-radius:999px;cursor:pointer;font-family:inherit;font-size:12px;
}
.hoot-toolbar button.ht-add:hover{background:rgba(194,160,100,.22)}
.hoot-toolbar .ht-ctrl{display:flex;align-items:center;gap:8px;background:var(--panel2);
border:1px solid var(--line);border-radius:999px;padding:5px 12px}
.hoot-toolbar .ht-ctrl label{margin:0;text-transform:uppercase;font-size:9.5px;letter-spacing:.6px;color:var(--muted)}
.hoot-toolbar input[type=range]{width:120px;-webkit-appearance:none;appearance:none;height:3px;
background:var(--line);border-radius:3px;cursor:pointer;padding:0}
.hoot-toolbar input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:13px;height:13px;border-radius:50%;background:var(--gold);cursor:pointer;border:2px solid var(--bg)}
.hoot-board{
display:flex;gap:14px;align-items:flex-start;
padding:16px 28px 40px;overflow-x:auto;min-height:60vh;
}
.hoot-col{
flex:0 0 var(--colw,320px);width:var(--colw,320px);
background:var(--panel);border:1px solid var(--line);border-radius:var(--radius-lg);
display:flex;flex-direction:column;max-height:calc(100vh - 170px);overflow:hidden;
transition:border-color .15s, opacity .15s;
}
.hoot-col.dragging{opacity:.4;border-color:var(--gold)}
.hoot-col.dragover{border-color:var(--gold)}
.hoot-col .hc-head{
display:flex;align-items:center;gap:8px;padding:11px 12px;
border-bottom:1px solid var(--line-soft);background:var(--panel2);cursor:grab;
}
.hoot-col .hc-head:active{cursor:grabbing}
.hoot-col .hc-head .av{width:9px;height:9px;border-radius:50%;flex-shrink:0}
.hoot-col .hc-head .hc-tt{min-width:0}
.hoot-col .hc-head .nm{font-size:12.5px;color:var(--ink);font-family:var(--serif);line-height:1.1;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hoot-col .hc-head .ty{font-size:9px;text-transform:uppercase;letter-spacing:.7px;color:var(--gold-dim);margin-top:2px}
.hoot-col .hc-head .ct{margin-left:auto;font-size:10px;color:var(--muted);background:var(--panel3);
border-radius:999px;padding:1px 7px}
.hoot-col .hc-head .rm{cursor:pointer;color:var(--muted);font-size:15px;line-height:1;padding:0 2px}
.hoot-col .hc-head .rm:hover{color:var(--danger)}
.hoot-col .hc-body{padding:9px;overflow-y:auto;display:flex;flex-direction:column;gap:7px}
.hoot-col .hc-empty{font-size:11px;color:var(--muted);font-style:italic;padding:14px 6px;text-align:center}
/* inbound feed card */
.feed-card{
background:var(--panel2);border:1px solid var(--line);border-radius:var(--radius-sm);
padding:10px 11px;transition:border-color .15s;
}
.feed-card:hover{border-color:var(--gold-dim)}
.feed-card.unread{border-left:3px solid var(--gold)}
.feed-card .fc-top{display:flex;align-items:center;gap:8px}
.feed-card .fc-av{width:26px;height:26px;border-radius:50%;flex-shrink:0;display:flex;align-items:center;
justify-content:center;font-size:11px;color:#0d0b06;font-weight:700}
.feed-card .fc-name{font-size:12px;color:var(--ink);font-weight:600;line-height:1.1}
.feed-card .fc-handle{font-size:10.5px;color:var(--muted)}
.feed-card .fc-when{margin-left:auto;font-size:10px;color:var(--muted);white-space:nowrap}
.feed-card .fc-text{font-size:12px;color:var(--ink-2);margin-top:7px;line-height:1.45;white-space:pre-wrap}
.feed-card .fc-actions{display:flex;gap:14px;margin-top:9px;color:var(--muted);font-size:11px}
.feed-card .fc-actions span{cursor:pointer;transition:color .12s}
.feed-card .fc-actions span:hover{color:var(--gold)}
.feed-card .fc-sim{font-size:8.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);
border:1px solid var(--line);border-radius:3px;padding:0 4px;margin-left:auto}
/* add-stream picker */
.picker label{margin-top:14px}
.picker .typegrid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin-top:6px}
.picker .typeopt{
border:1px solid var(--line);border-radius:var(--radius-sm);padding:9px 11px;cursor:pointer;
background:var(--panel2);transition:border-color .12s, background .12s;
}
.picker .typeopt:hover{border-color:var(--gold-dim);background:var(--panel3)}
.picker .typeopt.sel{border-color:var(--gold);background:var(--gold-faint2)}
.picker .typeopt .tn{font-size:12.5px;color:var(--ink)}
.picker .typeopt .tk{font-size:9px;text-transform:uppercase;letter-spacing:.6px;color:var(--gold-dim);margin-top:3px}
/* ─── Studio tools row (Compose) ─────────────────────────────── */
.studio-tools{display:flex;gap:7px;flex-wrap:wrap;margin:10px 0 2px}
.studio-tools button{
font-size:11.5px;padding:6px 12px;border-radius:999px;cursor:pointer;font-family:inherit;
background:var(--panel2);border:1px solid var(--line);color:var(--ink-2);
transition:border-color .12s, color .12s, background .12s;
}
.studio-tools button:hover{border-color:var(--gold-dim);color:var(--ink);background:var(--panel3)}
/* ─── Analytics view ─────────────────────────────────────────── */
.ana-wrap{max-width:1520px;margin:0 auto;padding:20px 28px}
.ana-note{font-size:11px;color:var(--warn);border:1px solid var(--warn-faint);background:var(--warn-faint);
border-radius:var(--radius-sm);padding:7px 11px;margin-bottom:18px;display:inline-block}
.ana-bars{display:flex;align-items:flex-end;gap:10px;height:150px;padding:10px 0;
border-bottom:1px solid var(--line-soft);margin-bottom:8px}
.ana-bars .bar{flex:1;background:linear-gradient(var(--gold),var(--gold-dim));border-radius:4px 4px 0 0;
min-height:4px;position:relative}
.ana-bars .bar .bl{position:absolute;bottom:-18px;left:0;right:0;text-align:center;font-size:10px;color:var(--muted)}
.ana-table{width:100%;border-collapse:collapse;margin-top:10px}
.ana-table th{text-align:left;font-size:9.5px;text-transform:uppercase;letter-spacing:.7px;color:var(--muted);
padding:8px 10px;border-bottom:1px solid var(--line)}
.ana-table td{padding:10px;border-bottom:1px solid var(--line-soft);font-size:12.5px;color:var(--ink-2)}
.ana-table td .av{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:7px}
.ana-table td b{color:var(--ink);font-weight:500}
/* ─── Bulk composer view ─────────────────────────────────────── */
.bulk-wrap{max-width:1100px;margin:0 auto;padding:20px 28px}
.bulk-wrap textarea{min-height:150px;font-family:ui-monospace,Menlo,monospace;font-size:12px}
.bulk-preview{margin-top:14px}
.bulk-row{display:flex;gap:10px;align-items:center;padding:9px 12px;border:1px solid var(--line-soft);
border-radius:var(--radius-sm);margin-bottom:6px;background:var(--panel2);font-size:12px}
.bulk-row.bad{border-color:var(--danger);background:var(--danger-faint)}
.bulk-row .br-ch{display:flex;gap:4px;flex-wrap:wrap}
.bulk-row .dot{width:8px;height:8px;border-radius:50%;display:inline-block}
.bulk-row .br-body{flex:1;min-width:0;color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bulk-row .br-when{color:var(--muted);font-size:11px;white-space:nowrap}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="brand-eyebrow">Launches · Social Composer</div>
<div class="brand-name">Designer Wallcoverings<span class="dot">.</span></div>
</div>
<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-media" onclick="showView('media')">Media</button>
<button id="nav-streams" onclick="showView('streams')">Streams</button>
<button id="nav-inbox" onclick="showView('inbox')">Inbox<span class="pill hide" id="inbox-pill">0</span></button>
<button id="nav-bulk" onclick="showView('bulk')">Bulk</button>
<button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
<button id="nav-analytics" onclick="showView('analytics')">Analytics</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">
<!-- LEFT: editor -->
<div class="col" style="flex:1.25">
<!-- 1. Channel selector rail -->
<div class="card">
<div class="section-label">
<span class="sl-step">01</span>
<span class="sl-title">Channels</span>
</div>
<div id="chanrail" class="chanrail"></div>
</div>
<!-- 2. Global editor -->
<div class="card">
<div class="section-label">
<span class="sl-step">02</span>
<span class="sl-title">Global Post</span>
</div>
<label>Launch title <span class="muted" style="display:inline;text-transform:none;letter-spacing:0">(admin only)</span></label>
<input type="text" id="launch-title" placeholder="e.g. Trending Wallcoverings Collection 2026 — Weekly Arrivals">
<label>Master body <span class="muted" style="display:inline;text-transform:none;letter-spacing:0">— selected channels inherit this by default</span></label>
<textarea id="global-body" placeholder='Write the master post… (the word "Wallpaper" is banned — use "Wallcoverings")'></textarea>
<div class="counter" id="global-counter"></div>
<div class="studio-tools">
<button type="button" onclick="aiCaption()" title="Generate an on-brand caption (local, $0)">✨ AI caption</button>
<button type="button" onclick="aiHashtags()" title="Append a DW hashtag set"># Hashtags</button>
<button type="button" onclick="bestTime()" title="Recommended posting slots">🕐 Best time</button>
<button type="button" onclick="shortenLinks()" title="Shorten URLs in the post to dw.ly links">🔗 Shorten links</button>
</div>
<!-- 4. Media attach -->
<label>Media</label>
<div class="media-drop" id="global-drop">Drag & drop, or click to add image / carousel / video URL or file ref</div>
<input type="file" id="global-file" class="hide" multiple accept="image/*,video/*">
<div class="media-list" id="global-media"></div>
</div>
<!-- 3. Per-channel tabs + 6. platform fields -->
<div class="card" id="perchan-card">
<div class="section-label">
<span class="sl-step">03</span>
<span class="sl-title">Per-channel Overrides</span>
</div>
<div class="ctabs" id="ctabs"></div>
<div id="perchan-editor" class="muted">Select 2+ channels to unlock per-channel overrides.</div>
</div>
<!-- 7. Scheduling -->
<div class="card">
<div class="section-label">
<span class="sl-step">07</span>
<span class="sl-title">Scheduling</span>
</div>
<div class="row">
<div>
<label>Mode</label>
<select id="sched-mode" onchange="renderSchedule()">
<option value="now">Post now</option>
<option value="schedule" selected>Schedule</option>
<option value="queue">Add to queue</option>
<option value="draft">Keep as draft</option>
</select>
</div>
<div id="sched-dt-wrap">
<label>Date & time</label>
<input type="datetime-local" id="sched-dt">
</div>
</div>
<div id="queue-wrap" class="hide">
<label>Preset queue slots (PT)</label>
<div class="queueslots" id="queue-slots"></div>
</div>
<p class="muted" style="margin-top:10px">Per-channel cadence overrides global schedule — set inside each channel tab.</p>
</div>
<!-- 9. State & approval -->
<div class="card">
<div class="section-label">
<span class="sl-step">09</span>
<span class="sl-title">State & Approval</span>
</div>
<div id="state-line"><span class="badge b-draft">draft</span></div>
<div class="gatebar" id="gatebar">
<strong>Ready to publish.</strong>
<div class="muted">No sign-off required — press Publish Now to go live.</div>
</div>
<div class="approval-actions">
<div class="row">
<button class="btn ghost" onclick="saveLaunch('draft')">Save draft</button>
<button class="btn btn-publish" id="btn-publish" onclick="publish()">⚡ Publish Now</button>
</div>
</div>
</div>
</div>
<!-- RIGHT: preview (sticky) -->
<div class="col preview-col">
<div class="card">
<div class="section-label">
<span class="sl-step">05</span>
<span class="sl-title">Live Preview</span>
</div>
<div class="ctabs" id="pv-tabs"></div>
<div class="preview-device">
<div id="preview"></div>
</div>
</div>
</div>
</div>
</div>
<!-- ============ MEDIA LIBRARY VIEW ============ -->
<div id="view-media" class="hide">
<div class="ml-toolbar">
<nav class="ml-sources tabs" id="ml-sources"></nav>
<div class="ht-spacer"></div>
<input type="text" id="ml-search" class="ml-search" placeholder="Search title / vendor / caption…">
<div class="ht-ctrl">
<label for="ml-dedup">Show</label>
<select id="ml-dedup" class="ml-search" style="min-width:0;padding:6px 8px">
<option value="image">Unique photos</option>
<option value="off">All colorways</option>
<option value="pattern">One per pattern</option>
</select>
</div>
<div class="ht-ctrl">
<label for="ml-density">Density</label>
<input type="range" id="ml-density" min="90" max="260" step="10" value="150">
</div>
</div>
<div class="ml-note muted" id="ml-note">Real assets from our dw_unified account — Shopify product images, our Instagram posts, and vendor / IG imagery. Click <b>+ Attach</b> to add to the current compose.</div>
<div class="ml-grid" id="ml-grid"></div>
<div class="ml-more"><button id="ml-more-btn" class="ht-add" onclick="mediaLoadMore()">Load more</button><span class="muted" id="ml-status"></span></div>
</div>
<!-- ============ CALENDAR VIEW ============ -->
<div id="view-calendar" class="hide">
<div class="wrap"><div class="col">
<div class="card">
<h3>Calendar</h3>
<div class="row" style="align-items:center;margin-bottom:16px">
<button class="btn ghost" onclick="calShift(-1)" style="flex:0 0 auto;padding:7px 14px">‹ Prev</button>
<div id="cal-label"></div>
<button class="btn ghost" onclick="calShift(1)" style="flex:0 0 auto;padding:7px 14px">Next ›</button>
</div>
<div class="cal" id="cal-grid"></div>
</div>
</div></div>
</div>
<!-- ============ DRAFTS VIEW ============ -->
<div id="view-drafts" class="hide">
<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 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="hoot-toolbar">
<span class="ht-title">Streams — add a column per account & stream type. Drag headers to reorder. Inbound feeds are simulated (local tool).</span>
<div class="ht-spacer"></div>
<div class="ht-ctrl">
<label for="hoot-width">Width</label>
<input type="range" id="hoot-width" min="260" max="440" step="20" value="320">
</div>
<button class="ht-add" onclick="openStreamPicker()">+ Add stream</button>
</div>
<div class="hoot-board" id="hoot-board"></div>
</div>
<!-- ============ INBOX VIEW (unified social inbox) ============ -->
<div id="view-inbox" class="hide">
<div class="inbox-wrap">
<div class="inbox-bar">
<div class="inbox-filters" id="inbox-filters"></div>
<div class="inbox-spacer"></div>
<select id="inbox-channel" onchange="renderInbox()" style="width:auto;min-width:150px"></select>
</div>
<div class="ana-note" style="margin:14px 0 4px">⚠ Simulated inbox — conversations are sample data. Replies are drafted locally; nothing is sent.</div>
<div id="inbox-list"></div>
</div>
</div>
<!-- ============ BULK COMPOSER VIEW ============ -->
<div id="view-bulk" class="hide">
<div class="bulk-wrap">
<h3 style="font-family:var(--serif);font-size:15px;color:var(--ink);margin-bottom:6px;font-weight:400">Bulk composer</h3>
<p class="muted" style="margin-bottom:14px">Paste one post per line — <code>YYYY-MM-DD HH:MM | channels | body</code>. Channels comma-separated (ig, fb, li, pin, tt, x, yt) or <code>all</code>. Each becomes a scheduled draft. The word "Wallpaper" is banned (server-rejected).</p>
<textarea id="bulk-input" placeholder="2026-07-01 09:00 | ig,pin | New grasscloth arrivals in Oatmeal & Greige — to the trade now.
2026-07-02 12:30 | all | Heritage damask book just landed. Memo samples shipping this week.
2026-07-03 18:00 | li | Why warm minimalism is dominating 2026 contract specs."></textarea>
<div class="studio-tools" style="margin-top:10px">
<button onclick="bulkParse()">Parse & preview</button>
<button onclick="document.getElementById('bulk-input').value='';document.getElementById('bulk-preview').innerHTML=''">Clear</button>
<button class="" id="bulk-create" onclick="bulkCreate()" style="display:none">Create drafts ↓</button>
</div>
<div class="bulk-preview" id="bulk-preview"></div>
</div>
</div>
<!-- ============ ANALYTICS VIEW ============ -->
<div id="view-analytics" class="hide">
<div class="ana-wrap">
<div class="ana-note">⚠ Simulated metrics — this is a local build tool. No live social analytics API is called.</div>
<div class="stat-grid" id="ana-stats"></div>
<div class="dash-section-title">Reach — last 7 days</div>
<div class="ana-bars" id="ana-bars"></div>
<div class="dash-section-title" style="margin-top:30px">Per-channel performance</div>
<table class="ana-table" id="ana-table"></table>
</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">Publish Now posts live to connected channels (Instagram via Norma → Meta Graph). A channel with no valid token records the post locally and reports <em>live:false</em> with the reason — connect/refresh that channel's token to post for real.</p>
<div id="approvals-list"></div>
</div>
</div>
<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' };
const QUEUE_SLOTS = ['09:00','10:30','12:00','13:30','16:00','18:00','19:30'];
let CHANNELS = [];
let state = null; // the launch being composed
let activeChanTab = null;
let activePvTab = null;
function blankLaunch(){
return { id:null, title:'', channelsSelected:[], global:{body:'',media:[]}, channels:{},
schedule:{mode:'schedule',datetime:''}, status:'draft',
approval:{approved:false,by:null,at:null}, published:false };
}
async function api(path, opts){
const r = await fetch(path, Object.assign({headers:{'Content-Type':'application/json'}}, opts||{}));
const j = await r.json().catch(()=>({}));
if(!r.ok) throw Object.assign(new Error(j.message||j.error||'error'), {body:j});
return j;
}
function toast(msg, isErr){
const t=document.getElementById('toast'); t.textContent=msg; t.className='toast'+(isErr?' err':'');
setTimeout(()=>t.classList.add('hide'), 4200);
}
// ---------- init ----------
async function init(){
CHANNELS = (await api('/api/channels')).channels;
// Deep-link: a "Build marketing" click from the CNCP new-SKU calendar lands here
// with ?compose=1&title=&img=&link=&sku=&vendor= → start a launch for that product.
if(maybeComposeFromURL()) return;
// load seed/first launch if exists
const ls = (await api('/api/launches')).launches;
state = ls.find(l=>l.id==='seed-trending-2026-w25') || ls[0] || blankLaunch();
hydrate();
}
// Build a fresh launch from product query-params (CNCP SKU → DW marketing on-ramp).
function maybeComposeFromURL(){
const q = new URLSearchParams(location.search);
if(!q.get('compose') && !q.get('title') && !q.get('img')) return false;
window.__dlCompose = true; // deep-link wins over the saved-view restore
const title = (q.get('title')||'').trim();
const img = (q.get('img')||'').trim();
const link = (q.get('link')||'').trim();
const vendor = (q.get('vendor')||'').trim();
state = blankLaunch();
state.title = title || 'New product launch';
// Starter caption — "Wallcoverings" never "Wallpaper" (server gate also enforces).
let body = title ? `Just in: ${title}` : 'New arrival just landed.';
if(vendor) body += ` by ${vendor}`;
body += '. New to the trade — designers, DM for pricing.';
if(link) body += `\n\nShop it: ${link}`;
state.global.body = body;
if(img) state.global.media = [{ type:'image', ref:img, alt:title }];
// default to the visual channels
['instagram','pinterest','facebook'].forEach(ch=>{
if(CHANNELS.some(c=>c.id===ch && c.connected)){
state.channelsSelected.push(ch);
state.channels[ch] = { body:'', media:[], fields:{}, schedule:{mode:state.schedule.mode,datetime:state.schedule.datetime} };
}
});
activeChanTab = state.channelsSelected[0] || null;
activePvTab = state.channelsSelected[0] || null;
hydrate();
showView('compose');
toast('Started a launch from the new SKU — edit & save as a draft.');
// clear params so a refresh doesn't re-trigger
try{ history.replaceState({}, '', location.pathname); }catch(e){}
return true;
}
function hydrate(){
document.getElementById('launch-title').value = state.title||'';
document.getElementById('global-body').value = state.global.body||'';
document.getElementById('sched-mode').value = state.schedule.mode||'schedule';
if(state.schedule.datetime) document.getElementById('sched-dt').value = toLocalInput(state.schedule.datetime);
renderChanRail(); renderGlobalMedia(); renderCounter('global');
renderCTabs(); renderSchedule(); renderState(); renderPreview();
}
function toLocalInput(iso){ try{const d=new Date(iso);const p=n=>String(n).padStart(2,'0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;}catch{return '';} }
// ---------- 1. channel rail ----------
function renderChanRail(){
const el=document.getElementById('chanrail'); el.innerHTML='';
CHANNELS.forEach(c=>{
const sel = state.channelsSelected.includes(c.id);
const d=document.createElement('div');
d.className='chip'+(sel?' sel':'')+(c.connected?'':' disabled');
// refined: colored dot, not full candy avatar
d.innerHTML=`<span class="av" style="background:${COLORS[c.id]}"></span>${c.name}`
+ (c.connected?'':' <span class="connect">connect</span>');
if(c.connected) d.onclick=()=>toggleChan(c.id);
el.appendChild(d);
});
}
function toggleChan(id){
const i=state.channelsSelected.indexOf(id);
if(i>=0){ state.channelsSelected.splice(i,1); delete state.channels[id]; }
else {
state.channelsSelected.push(id);
state.channels[id] = state.channels[id] || { body:'', media:[], fields:{}, schedule:{mode:state.schedule.mode,datetime:state.schedule.datetime} };
}
if(!state.channelsSelected.includes(activeChanTab)) activeChanTab=state.channelsSelected[0]||null;
if(!state.channelsSelected.includes(activePvTab)) activePvTab=state.channelsSelected[0]||null;
renderChanRail(); renderCTabs(); renderPreview();
}
// ---------- 2. global editor ----------
document.getElementById('launch-title').addEventListener('input',e=>{state.title=e.target.value;});
document.getElementById('global-body').addEventListener('input',e=>{state.global.body=e.target.value;renderCounter('global');renderPreview();});
// ---------- 4. media ----------
function wireDrop(dropId, fileId, getArr){
const drop=document.getElementById(dropId), file=document.getElementById(fileId);
drop.onclick=()=>{
const url=prompt('Add media — paste an image/video URL or local file ref (or Cancel to pick a file):');
if(url){ getArr().push({type:guessType(url),ref:url,alt:''}); refreshMedia(); }
else file.click();
};
['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('over');}));
['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('over');}));
drop.addEventListener('drop',e=>{ [...e.dataTransfer.files].forEach(f=>addFile(f,getArr())); });
file.addEventListener('change',e=>{ [...e.target.files].forEach(f=>addFile(f,getArr())); e.target.value=''; });
}
function guessType(ref){ return /\.(mp4|mov|webm)$/i.test(ref)?'video':'image'; }
function addFile(f,arr){
const url=URL.createObjectURL(f);
arr.push({type:f.type.startsWith('video')?'video':'image', ref:url, name:f.name, alt:''});
refreshMedia();
}
function refreshMedia(){ renderGlobalMedia(); renderPerChanMedia(); renderPreview(); }
function renderMediaInto(elId, arr){
const el=document.getElementById(elId); if(!el) return; el.innerHTML='';
arr.forEach((m,i)=>{
const d=document.createElement('div'); d.className='media-item';
const label=m.name||m.ref.split('/').pop()||m.type;
d.innerHTML = m.type==='image' && /^blob:|^https?:|^\/api\//.test(m.ref)
? `<img src="${m.ref}" alt="">` : `<span>${m.type}<br>${label.slice(0,18)}</span>`;
d.innerHTML += `<span class="x" title="remove">×</span><span class="tag">${m.type}</span>`;
d.querySelector('.x').onclick=(e)=>{e.stopPropagation();arr.splice(i,1);refreshMedia();};
el.appendChild(d);
});
}
function renderGlobalMedia(){ renderMediaInto('global-media', state.global.media); }
// ---------- counters ----------
function renderCounter(scope){
if(scope==='global'){
const el=document.getElementById('global-counter');
el.textContent = `${(state.global.body||'').length} chars (master)`;
}
}
// ---------- 3 + 6. per-channel tabs ----------
function renderCTabs(){
const el=document.getElementById('ctabs'); el.innerHTML='';
const sel=state.channelsSelected;
if(sel.length<2){
document.getElementById('perchan-editor').innerHTML='<span class="muted">Select 2+ channels to unlock per-channel overrides.</span>';
el.innerHTML=''; renderPvTabs(); return;
}
if(!sel.includes(activeChanTab)) activeChanTab=sel[0];
sel.forEach(id=>{
const b=document.createElement('button'); b.textContent=chanName(id);
if(id===activeChanTab) b.className='active';
b.onclick=()=>{activeChanTab=id;renderCTabs();};
el.appendChild(b);
});
renderPerChanEditor(); renderPvTabs();
}
function chanName(id){ return (CHANNELS.find(c=>c.id===id)||{}).name||id; }
function ensureChan(id){ return state.channels[id] = state.channels[id] || {body:'',media:[],fields:{},schedule:{mode:state.schedule.mode,datetime:state.schedule.datetime}}; }
function renderPerChanEditor(){
const id=activeChanTab; const c=ensureChan(id);
const limit=CHAR_LIMITS[id];
const effBody = c.body || state.global.body || '';
let html = `
<label>${chanName(id)} copy <span class="muted" style="display:inline;text-transform:none;letter-spacing:0">(overrides master; blank = inherit master)</span></label>
<textarea id="pc-body" placeholder="Inherit master, or override here…">${esc(c.body||'')}</textarea>
<div class="counter" id="pc-counter"></div>
<label>${chanName(id)} media <span class="muted" style="display:inline;text-transform:none;letter-spacing:0">(blank = inherit master media)</span></label>
<div class="media-drop" id="pc-drop">Add ${chanName(id)} media</div>
<input type="file" id="pc-file" class="hide" multiple accept="image/*,video/*">
<div class="media-list" id="pc-media"></div>`;
html += platformFields(id, c);
// per-channel schedule
html += `
<div class="field-block">
<label>${chanName(id)} schedule (PT cadence)</label>
<div class="row">
<select id="pc-mode">
${['now','schedule','queue','draft'].map(m=>`<option value="${m}" ${ (c.schedule&&c.schedule.mode)===m?'selected':''}>${m}</option>`).join('')}
</select>
<input type="datetime-local" id="pc-dt" value="${c.schedule&&c.schedule.datetime?toLocalInput(c.schedule.datetime):''}">
</div>
</div>`;
document.getElementById('perchan-editor').innerHTML=html;
const bodyEl=document.getElementById('pc-body');
bodyEl.addEventListener('input',()=>{ c.body=bodyEl.value; renderPcCounter(id); renderPreview(); });
renderPcCounter(id);
wireDrop('pc-drop','pc-file',()=>ensureChan(activeChanTab).media);
renderPerChanMedia();
wirePlatformFields(id,c);
document.getElementById('pc-mode').onchange=e=>{ c.schedule=c.schedule||{}; c.schedule.mode=e.target.value; };
document.getElementById('pc-dt').onchange=e=>{ c.schedule=c.schedule||{}; c.schedule.datetime=e.target.value?new Date(e.target.value).toISOString():''; };
}
function renderPcCounter(id){
const el=document.getElementById('pc-counter'); if(!el) return;
const c=ensureChan(id); const len=(c.body||state.global.body||'').length; const lim=CHAR_LIMITS[id];
el.textContent=`${len} / ${lim} chars`;
el.className='counter'+(len>lim?' over':'');
}
function renderPerChanMedia(){ if(activeChanTab && state.channelsSelected.length>=2) renderMediaInto('pc-media', ensureChan(activeChanTab).media); }
// 6. platform-specific fields
function platformFields(id,c){
const f=c.fields||{}; const fb=v=>esc(v||'');
if(id==='instagram') return `<div class="field-block"><label>IG first comment</label><input type="text" id="f-firstComment" value="${fb(f.firstComment)}" placeholder="First comment (hashtags / link)"></div>`;
if(id==='youtube') return `<div class="field-block"><label>YouTube title</label><input type="text" id="f-ytTitle" value="${fb(f.ytTitle)}"><label>Thumbnail ref</label><input type="text" id="f-ytThumbnail" value="${fb(f.ytThumbnail)}"></div>`;
if(id==='pinterest') return `<div class="field-block"><label>Pin title</label><input type="text" id="f-pinTitle" value="${fb(f.pinTitle)}"><label>Board</label><input type="text" id="f-board" value="${fb(f.board)}"><label>Destination link</label><input type="text" id="f-destinationLink" value="${fb(f.destinationLink)}"></div>`;
if(id==='twitter') return `<div class="field-block"><label>Thread split <span class="muted" style="display:inline;text-transform:none;letter-spacing:0">(one tweet per line)</span></label><textarea id="f-thread" style="min-height:90px">${fb((f.thread||[]).join('\n'))}</textarea></div>`;
return '';
}
function wirePlatformFields(id,c){
c.fields=c.fields||{};
const bind=(elId,key)=>{const e=document.getElementById(elId); if(e) e.addEventListener('input',()=>{c.fields[key]=e.value;renderPreview();});};
bind('f-firstComment','firstComment'); bind('f-ytTitle','ytTitle'); bind('f-ytThumbnail','ytThumbnail');
bind('f-pinTitle','pinTitle'); bind('f-board','board'); bind('f-destinationLink','destinationLink');
const th=document.getElementById('f-thread'); if(th) th.addEventListener('input',()=>{c.fields.thread=th.value.split('\n').filter(Boolean);renderPreview();});
}
// ---------- 7. scheduling ----------
function renderSchedule(){
const mode=document.getElementById('sched-mode').value;
state.schedule.mode=mode;
document.getElementById('sched-dt-wrap').style.display = (mode==='schedule')?'block':'none';
document.getElementById('queue-wrap').classList.toggle('hide', mode!=='queue');
if(mode==='queue'){
const q=document.getElementById('queue-slots'); q.innerHTML='';
QUEUE_SLOTS.forEach(t=>{const b=document.createElement('button');b.textContent=t+' PT';
b.onclick=()=>{ applyQueueSlot(t); }; q.appendChild(b);});
}
}
document.getElementById('sched-dt').addEventListener('change',e=>{ state.schedule.datetime = e.target.value?new Date(e.target.value).toISOString():''; });
function applyQueueSlot(hhmm){
// assign next-day slot at chosen time PT to currently selected channels lacking a time
const [h,m]=hhmm.split(':').map(Number);
const d=new Date(); d.setDate(d.getDate()+1); d.setHours(h,m,0,0);
state.channelsSelected.forEach(id=>{ const c=ensureChan(id); c.schedule=c.schedule||{}; c.schedule.mode='schedule'; c.schedule.datetime=d.toISOString(); });
toast(`Queued selected channels at ${hhmm} PT (${d.toLocaleDateString()})`);
}
// ---------- 5. preview ----------
function renderPvTabs(){
const el=document.getElementById('pv-tabs'); el.innerHTML='';
const sel=state.channelsSelected.length?state.channelsSelected:CHANNELS.map(c=>c.id);
if(!sel.includes(activePvTab)) activePvTab=sel[0];
sel.forEach(id=>{const b=document.createElement('button');b.textContent=chanName(id);
if(id===activePvTab)b.className='active'; b.onclick=()=>{activePvTab=id;renderPvTabs();renderPreview();}; el.appendChild(b);});
}
function effective(id){
const c=state.channels[id]||{};
return { body:(c.body||state.global.body||''), media:((c.media&&c.media.length)?c.media:state.global.media)||[], fields:c.fields||{} };
}
function renderPreview(){
renderPvTabs();
const id=activePvTab; if(!id){document.getElementById('preview').innerHTML='<span class="muted">Select a channel.</span>';return;}
const e=effective(id); const lim=CHAR_LIMITS[id];
const img = e.media[0];
const imgBox = img ? (img.type==='image'&&/^blob:|^https?:/.test(img.ref)
? `<div class="pvimg"><img src="${img.ref}" style="width:100%;height:100%;object-fit:cover"></div>`
: `<div class="pvimg">${img.type.toUpperCase()} · ${esc((img.name||img.ref).split('/').pop())}</div>`)
: `<div class="pvimg">no media</div>`;
let body=e.body, extra='';
let cls='pv '+id;
if(id==='twitter'){
const thread=(e.fields.thread&&e.fields.thread.length)?e.fields.thread:[body];
body = thread.map(t=>{
const over=t.length>280;
return `<div class="tw">${esc(t.slice(0,280))}${over?'…':''}<div class="counter ${over?'over':''}">${t.length}/280</div></div>`;
}).join('');
extra = `<div class="pvbody thread">${body}</div>`;
document.getElementById('preview').innerHTML = pvShell(id,'@designerwall', imgBox, extra, 'X / Twitter');
return;
}
const over = e.body.length>lim;
const counter = `<div class="pvbody"><div class="counter ${over?'over':''}">${e.body.length}/${lim}</div></div>`;
let head='', titleLine='';
if(id==='youtube'){ titleLine=`<div class="pvtitle">${esc(e.fields.ytTitle||state.title||'Untitled')}</div>`; }
if(id==='pinterest' && e.fields.pinTitle){ titleLine=`<div class="pvtitle">${esc(e.fields.pinTitle)}</div>`; }
let firstC = (id==='instagram'&&e.fields.firstComment)?`<div class="pvbody muted">↳ first comment: ${esc(e.fields.firstComment)}</div>`:'';
let pinMeta = (id==='pinterest')?`<div class="pvbody muted">Board: ${esc(e.fields.board||'—')} · → ${esc(e.fields.destinationLink||'—')}</div>`:'';
const inner = `${(id==='youtube')?imgBox+titleLine:''}${(id!=='youtube')?titleLine:''}${(id!=='youtube')?imgBox:''}<div class="pvbody">${esc(e.body)}</div>${counter}${firstC}${pinMeta}`;
document.getElementById('preview').innerHTML = pvShell(id,'Designer Wallcoverings', inner, '', chanName(id));
}
function pvShell(id, name, inner, extra, label){
return `<div class="pv ${id}">
<div class="pvhead">
<div class="pvav" style="background:${COLORS[id]}"></div>
<div><div class="pvname">${name}</div><div class="pvsub">${label} preview</div></div>
</div>
${inner}${extra}
<div class="pvactions"><span>♥ Like</span><span>💬 Comment</span><span>↗ Share</span></div>
</div>`;
}
// ---------- 9. state / approval ----------
function renderState(){
const m={draft:'b-draft',pending_approval:'b-pending',approved:'b-approved',published:'b-published'};
document.getElementById('state-line').innerHTML=`<span class="badge ${m[state.status]||'b-draft'}">${(state.status||'draft').replace('_',' ')}</span>`
+ (state.approval&&state.approval.approved?` <span class="muted">· signed off by ${state.approval.by} ${new Date(state.approval.at).toLocaleString()}</span>`:'');
const gate=document.getElementById('gatebar');
const pub=state.published||state.status==='published';
const btn=document.getElementById('btn-publish');
if(btn){ btn.disabled=pub; btn.textContent=pub?'✓ Published':'⚡ Publish Now'; }
gate.className='gatebar'+(pub?'':' ');
gate.querySelector('strong').textContent = pub?'Published — live.':'Ready to publish.';
}
// ---------- actions ----------
async function saveLaunch(forceStatus){
if(forceStatus) state.status=forceStatus;
// pull current title/body
state.title=document.getElementById('launch-title').value;
state.global.body=document.getElementById('global-body').value;
try{
const j=await api('/api/launches',{method:'POST',body:JSON.stringify(state)});
state=j.launch; toast('Saved draft ✓'); renderState();
}catch(e){
if(e.body&&e.body.error==='BANNED_WORD'){ toast('Blocked: "Wallpaper" is banned — use "Wallcoverings". '+(e.body.hits||[]).join('; '),true); }
else toast('Save failed: '+e.message,true);
}
}
async function submitApproval(){
await saveLaunch('draft');
if(!state.id) return;
try{ const j=await api(`/api/launches/${state.id}/submit-approval`,{method:'POST'}); state=j.launch; renderState(); toast('Submitted for approval — awaiting Steve sign-off.'); }
catch(e){ toast('Failed: '+e.message,true); }
}
async function approve(){
if(!state.id){ toast('Save the launch first.',true); return; }
// local Steve sign-off token gate
const token=prompt('Steve sign-off required to unlock publish. Enter sign-off token (STEVE-GO):');
if(token===null) return;
try{ const j=await api(`/api/launches/${state.id}/approve`,{method:'POST',body:JSON.stringify({token,by:'Steve'})}); state=j.launch; renderState(); toast('Signed off ✓ — Publish Now posts live to connected channels.'); }
catch(e){ toast('Sign-off rejected: '+e.message,true); }
}
async function publish(){
if(!state.id){ await saveLaunch('draft'); if(!state.id) return; }
try{ const j=await api(`/api/launches/${state.id}/publish`,{method:'POST'}); state=j.launch; renderState();
toast(j.note||'Published.', j.live===false); }
catch(e){ toast('Publish failed: '+e.message,true); }
}
// ---------- views ----------
const VIEWS=['dashboard','compose','media','streams','inbox','bulk','calendar','analytics','drafts','approvals'];
function showView(v){
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==='media') renderMedia();
if(v==='streams') renderStreams();
if(v==='inbox') renderInbox();
if(v==='bulk') {/* static */}
if(v==='calendar') renderCalendar();
if(v==='analytics') renderAnalytics();
if(v==='drafts') renderDrafts();
if(v==='approvals') renderApprovals();
}
// 8. calendar
let calRef=new Date();
function calShift(n){ calRef.setMonth(calRef.getMonth()+n); renderCalendar(); }
async function renderCalendar(){
const {events}=await api('/api/calendar');
const y=calRef.getFullYear(), mo=calRef.getMonth();
document.getElementById('cal-label').textContent=calRef.toLocaleString(undefined,{month:'long',year:'numeric'});
const first=new Date(y,mo,1), startDow=first.getDay(), days=new Date(y,mo+1,0).getDate();
const grid=document.getElementById('cal-grid'); grid.innerHTML='';
['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dh';h.textContent=d;grid.appendChild(h);});
for(let i=0;i<startDow;i++){const c=document.createElement('div');c.className='cell';grid.appendChild(c);}
for(let d=1;d<=days;d++){
const cell=document.createElement('div'); cell.className='cell';
cell.innerHTML=`<div class="dn">${d}</div>`;
events.filter(e=>{const dt=new Date(e.datetime);return dt.getFullYear()===y&&dt.getMonth()===mo&&dt.getDate()===d;})
.forEach(e=>{const ev=document.createElement('div');ev.className='ev';
ev.style.borderLeftColor=COLORS[e.channel]||'#c2a064';
ev.title=`${e.title}\n${e.channel} · ${new Date(e.datetime).toISOString()}\nstatus: ${e.status}`;
ev.textContent=`${new Date(e.datetime).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})} ${chanName(e.channel)}`;
cell.appendChild(ev);});
grid.appendChild(cell);
}
}
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 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 ${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="${esc(l.created_at||'')}">🕓 ${whenStr}</div>`;
d.onclick=()=>openLaunchModal(l.id);
el.appendChild(d);
});
}
// ---------- dashboard ----------
// ---------- Media Library ----------
const mlState={ source:'all', q:'', offset:0, limit:60, loading:false, sourcesLoaded:false };
let mlSearchTimer=null;
async function renderMedia(){
// density slider -> --mlmin (persisted)
const dens=document.getElementById('ml-density');
const savedDens=localStorage.getItem('dwlaunch:mlmin');
if(savedDens){ dens.value=savedDens; }
document.getElementById('ml-grid').style.setProperty('--mlmin', dens.value+'px');
dens.oninput=()=>{ document.getElementById('ml-grid').style.setProperty('--mlmin',dens.value+'px');
try{localStorage.setItem('dwlaunch:mlmin',dens.value);}catch(e){} };
// search (debounced)
const search=document.getElementById('ml-search');
search.oninput=()=>{ clearTimeout(mlSearchTimer); mlSearchTimer=setTimeout(()=>{
mlState.q=search.value.trim(); mlReset(); }, 300); };
// dedup mode toggle (default = unique photos: hide byte-identical reuploads,
// but keep the colorway names aggregated onto the surviving tile), persisted
const dd=document.getElementById('ml-dedup');
mlState.dedup = localStorage.getItem('dwlaunch:dedup') || 'image';
dd.value=mlState.dedup;
dd.onchange=()=>{ mlState.dedup=dd.value; try{localStorage.setItem('dwlaunch:dedup',dd.value);}catch(e){} mlReset(); };
if(!mlState.sourcesLoaded) await mlLoadSources();
mlReset();
}
async function mlLoadSources(){
const tabs=document.getElementById('ml-sources');
try{
const d=await api('/api/media/sources');
mlState.sourcesLoaded=true;
tabs.innerHTML='';
(d.sources||[]).forEach(s=>{
const b=document.createElement('button');
b.dataset.source=s.id;
b.innerHTML=`${s.name}`+(s.count!=null?`<span class="ct">${s.count.toLocaleString()}</span>`:'');
b.classList.toggle('active', s.id===mlState.source);
b.onclick=()=>{ mlState.source=s.id; [...tabs.children].forEach(x=>x.classList.toggle('active',x===b)); mlReset(); };
tabs.appendChild(b);
});
document.getElementById('ml-note').innerHTML =
`Real assets from our <b>dw_unified</b> account — `+
`${(d.counts.shopify||0).toLocaleString()} Shopify product images · `+
`${(d.counts.instagram||0).toLocaleString()} of our Instagram posts · `+
`${(d.counts.vendor||0).toLocaleString()} vendor / IG. Click <b>+ Attach</b> to add to the current compose.`;
}catch(e){
tabs.innerHTML='';
document.getElementById('ml-note').innerHTML=
`<span style="color:#e0a0a0">Media DB unreachable (${e.message}). The composer still works with file/URL attach.</span>`;
}
}
// Normalised image identity for dedup. Collapses the same photo re-uploaded
// once per colorway/variant — Shopify suffixes a 4-hex hash to the filename
// (Y_Shagreen_WALLPAPER_e3e1.jpg, _f1c1.jpg, _3347.jpg…), so strip that +
// any ?v= cache-buster and key on the base filename.
function mlImgKey(u){
if(!u) return u;
try{ u=decodeURIComponent(u.replace(/^\/api\/media\/img\?u=/,'')); }catch(e){}
u=u.split('?')[0];
const base=(u.split('/').pop()||u).toLowerCase();
return base.replace(/_[0-9a-f]{4}\.(jpg|jpeg|png|webp|gif)$/,'') || base;
}
// Dedup key per media item. For Shopify products, collapse a whole PATTERN to
// one tile (colorways/shots like "Darcy - Smoked Pearl", "Darcy - Vine Black",
// the _01/_02 reshoots all reuse the same room photo) by keying on
// vendor + pattern-name (title before the first " - " / " | "). Other sources
// (IG posts) have no such convention → fall back to image identity.
function mlItemKey(m){
if(m.source==='shopify' && m.title){
const pattern=m.title.split(/\s[-|]\s/)[0].trim().toLowerCase().replace(/\s+/g,' ');
if(pattern) return 'p:'+(m.vendor||'').toLowerCase()+'|'+pattern;
}
return 'i:'+mlImgKey(m.url);
}
// colorway name from a product title: "Shagreen - Como Blue wallcovering | Zoffany" -> "Como Blue"
function mlColorway(m){
if(!m || !m.title) return '';
const parts=m.title.split(/\s-\s/);
if(parts.length<2) return '';
return parts.slice(1).join(' - ').replace(/\b(wall ?covering|wall ?paper|fabric)\b.*$/i,'').replace(/\|.*$/,'').trim();
}
// fold a duplicate item onto the already-shown tile: track its colorway and
// show a "· N colorways" chip so no colorway info is lost when photos collapse.
function mlFoldColorway(entry, m){
const cw=mlColorway(m);
if(cw && !entry.colorways.includes(cw)) entry.colorways.push(cw);
const chip=entry.el.querySelector('.cw-chip');
if(chip && entry.colorways.length>1){
chip.style.display='';
chip.textContent='+'+(entry.colorways.length-1)+' more colorway'+(entry.colorways.length-1===1?'':'s');
chip.title=entry.colorways.join(' · ');
}
}
function mlReset(){
mlState.offset=0;
mlState.seen=new Map();
mlState.dups=0;
document.getElementById('ml-grid').innerHTML='';
document.getElementById('ml-status').textContent='';
mediaLoadMore();
}
async function mediaLoadMore(){
if(mlState.loading) return;
mlState.loading=true;
const btn=document.getElementById('ml-more-btn'), status=document.getElementById('ml-status');
btn.disabled=true; status.textContent='Loading…';
try{
const qs=new URLSearchParams({source:mlState.source,q:mlState.q,limit:mlState.limit,offset:mlState.offset});
const d=await api('/api/media?'+qs.toString());
const grid=document.getElementById('ml-grid');
if(!mlState.seen) mlState.seen=new Set();
let added=0;
const dedupOn = mlState.dedup && mlState.dedup!=='off';
if(!(mlState.seen instanceof Map)) mlState.seen=new Map();
(d.items||[]).forEach(m=>{
if(dedupOn){
// 'image' mode: dedup on the actual image content hash (byte-identical
// colorway reuploads collapse) — fall back to filename if not hashed.
const k = mlState.dedup==='pattern' ? mlItemKey(m)
: (m.imgHash ? 'h:'+m.imgHash : 'i:'+mlImgKey(m.url));
const ex=mlState.seen.get(k);
if(ex){ mlState.dups=(mlState.dups||0)+1; mlFoldColorway(ex,m); return; }
const card=mlCard(m);
const entry={el:card, colorways:[mlColorway(m)].filter(Boolean)};
mlState.seen.set(k, entry);
grid.appendChild(card); added++;
return;
}
grid.appendChild(mlCard(m)); added++;
});
mlState.offset += (d.items||[]).length;
btn.style.display = d.hasMore ? '' : 'none';
// if a whole page collapsed to duplicates, keep pulling so the grid grows
if(added===0 && d.hasMore && mlState.offset < 600){ mlState.loading=false; return mediaLoadMore(); }
if(grid.children.length===0){
if(mlState.source==='vendor' && !mlState.q){
grid.innerHTML=`<div class="ml-empty">No vendor Instagram posts ingested yet.<br>`+
`<span class="muted">Handles for 20 major houses are seeded (Schumacher, Thibaut, de Gournay, Cole & Son…). `+
`Populate via Meta Graph (token) or by pasting post URLs — then real, cached images appear here.</span></div>`;
} else {
grid.innerHTML=`<div class="ml-empty muted">No matches.</div>`;
}
}
const shown=grid.querySelectorAll('.ml-card').length;
status.textContent = shown.toLocaleString()+' shown'+(mlState.dups?` · ${mlState.dups.toLocaleString()} duplicate${mlState.dups===1?'':'s'} hidden`:'');
}catch(e){ status.textContent='Error: '+e.message; }
finally{ mlState.loading=false; btn.disabled=false; }
}
function mlCard(m){
const c=document.createElement('div'); c.className='ml-card';
const srcLabel={shopify:'Shopify',instagram:'Instagram',vendor:'Vendor IG'}[m.source]||m.source;
const safe=s=>(s||'').replace(/"/g,'"').replace(/</g,'<');
// vendor IG urls are ALREADY a local cached path (/api/media/img?u=…) — use
// as-is. http(s) urls: shopify CDN loads direct (fast); others via the proxy.
const isLocal = /^\//.test(m.url);
const proxied = isLocal ? m.url : '/api/media/img?u='+encodeURIComponent(m.url);
const initialSrc = (m.source==='shopify') ? safe(m.url) : proxied;
c.innerHTML=
`<span class="src">${srcLabel}</span>`+
`<img class="thumb" loading="lazy" src="${initialSrc}" alt="${safe(m.title)}" `+
`data-raw="${safe(m.url)}" data-proxied="0">`+
`<div class="meta"><div class="mt" title="${safe(m.title)}">${safe(m.title)}</div>`+
(m.vendor?`<div class="mv">${safe(m.vendor)}</div>`:'')+
`<div class="cw-chip" style="display:none"></div>`+
(m.link?`<a class="mv" style="color:var(--gold-dim)" href="${safe(m.link)}" target="_blank" rel="noopener noreferrer">view post ↗</a>`:'')+`</div>`+
`<div class="ml-acts"><button class="att">+ Attach</button><button class="rep">↻ Repost / Use</button></div>`;
// proxy fallback on load error (one retry), then dim.
const img=c.querySelector('img.thumb');
img.onerror=function(){
const raw=this.dataset.raw;
if(this.dataset.proxied==='0' && /^https?:/.test(raw)){ this.dataset.proxied='1'; this.src='/api/media/img?u='+encodeURIComponent(raw); }
else { this.style.opacity=.2; this.alt='(image unavailable)'; this.onerror=null; }
};
c.querySelector('.att').onclick=(e)=>{
const b=e.currentTarget;
state.global.media.push({type:guessType(m.url),ref:m.url,alt:m.title||'',name:(m.title||'').slice(0,40)});
refreshMedia();
b.textContent='✓ Attached'; b.classList.add('added');
setTimeout(()=>{ b.textContent='+ Attach'; b.classList.remove('added'); },1600);
toast('Added to compose — '+(state.global.media.length)+' media item'+(state.global.media.length>1?'s':''));
};
c.querySelector('.rep').onclick=()=>mlRepost(m);
return c;
}
// Repost / Use — start a NEW draft launch pre-filled with this asset + an
// attribution line, then jump to Compose. Nothing posts live: it flows through
// the normal Save → Submit → Steve sign-off → (simulated) publish gate.
function mlRepost(m){
const credit = m.source==='vendor'
? `\n\n📷 via ${m.vendor||'vendor'}${m.link?` — original: ${m.link}`:''}`
: '';
const body = (m.caption || m.title || '').slice(0,400) + credit;
state = {
id:null,
title: (m.source==='vendor'?'Repost — ':'Use — ')+(m.title||'asset').slice(0,50),
channelsSelected:['instagram'],
global:{ body, media:[{type:guessType(m.url),ref:m.url,alt:m.title||'',name:(m.title||'').slice(0,40)}] },
channels:{}, schedule:{mode:'draft',datetime:''}, status:'draft',
approval:{approved:false,by:null,at:null}, published:false,
repostOf: m.link||m.url, repostSource:m.source
};
activeChanTab=null; activePvTab=null;
showView('compose'); hydrate();
toast(m.source==='vendor'
? 'Repost draft started — review credit/caption, then Save → Submit for sign-off.'
: 'Draft started from asset — edit, then Save.');
}
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 (TRUE Hootsuite model: user-addable columns) ----------
// Each column = { id, channel:'all'|chanId, type:streamType, q:'' }
let STREAM_TYPES=[]; // loaded from server
let hootCols=[]; // active columns (persisted)
const TYPE_LABEL={scheduled:'Scheduled',pending:'Pending approval',published:'Published / Sent',
drafts:'Drafts',mentions:'Mentions',comments:'Comments',dms:'Direct messages',home:'Home feed',search:'Keyword / # search'};
function loadHootCols(){
try{ const s=JSON.parse(localStorage.getItem('dwlaunch:hootCols')); if(Array.isArray(s)&&s.length) return s; }catch(e){}
// sensible Hootsuite-style default board
return [
{id:cid(),channel:'all',type:'pending',q:''},
{id:cid(),channel:'instagram',type:'mentions',q:''},
{id:cid(),channel:'instagram',type:'comments',q:''},
{id:cid(),channel:'all',type:'scheduled',q:''},
{id:cid(),channel:'linkedin',type:'home',q:''},
{id:cid(),channel:'all',type:'search',q:'grasscloth'}
];
}
function cid(){ return 'c'+Math.random().toString(36).slice(2,8); }
function saveHootCols(){ try{ localStorage.setItem('dwlaunch:hootCols',JSON.stringify(hootCols)); }catch(e){} }
async function renderStreams(){
if(!STREAM_TYPES.length){ try{ STREAM_TYPES=(await api('/api/stream-types')).types; }catch(e){} }
if(!hootCols.length) hootCols=loadHootCols();
const board=document.getElementById('hoot-board'); board.innerHTML='';
hootCols.forEach(col=>{
const el=document.createElement('div'); el.className='hoot-col'; el.dataset.id=col.id; el.draggable=false;
const chName=col.channel==='all'?'All accounts':chanName(col.channel);
const dot=col.channel==='all'?'var(--gold)':(COLORS[col.channel]||'var(--gold)');
const tLabel=TYPE_LABEL[col.type]||col.type;
const sub=col.type==='search'&&col.q?`“${esc(col.q)}”`:tLabel;
el.innerHTML=`
<div class="hc-head" draggable="true">
<span class="av" style="background:${dot}"></span>
<span class="hc-tt"><span class="nm">${esc(chName)}</span><span class="ty">${esc(sub)}</span></span>
<span class="ct">…</span>
<span class="rm" title="Remove column">×</span>
</div>
<div class="hc-body"><div class="hc-empty">Loading…</div></div>`;
board.appendChild(el);
el.querySelector('.rm').onclick=()=>{ hootCols=hootCols.filter(c=>c.id!==col.id); saveHootCols(); renderStreams(); };
wireColDrag(el);
loadColumn(col, el);
});
// trailing add-column tile
const add=document.createElement('div'); add.className='hoot-col';
add.style.cssText='flex:0 0 200px;width:200px;align-items:center;justify-content:center;min-height:160px;cursor:pointer;border-style:dashed;background:transparent';
add.innerHTML='<div style="text-align:center;color:var(--muted);padding:30px 10px"><div style="font-size:26px;color:var(--gold-dim)">+</div><div style="font-size:12px;margin-top:6px">Add stream</div></div>';
add.onclick=openStreamPicker; board.appendChild(add);
}
async function loadColumn(col, el){
const body=el.querySelector('.hc-body'); const ct=el.querySelector('.ct');
let items=[];
try{
const j=await api(`/api/stream?channel=${encodeURIComponent(col.channel)}&type=${encodeURIComponent(col.type)}&q=${encodeURIComponent(col.q||'')}`);
items=j.items||[];
}catch(e){ body.innerHTML='<div class="hc-empty">Could not load.</div>'; ct.textContent='!'; return; }
ct.textContent=items.length;
if(!items.length){ body.innerHTML=`<div class="hc-empty">Nothing here yet${col.type==='search'&&col.q?` for “${esc(col.q)}”`:''}.</div>`; return; }
body.innerHTML=items.map(it=>it.outbound?outboundCard(it):feedCard(it)).join('');
body.querySelectorAll('[data-open]').forEach(x=>x.onclick=()=>openLaunchModal(x.getAttribute('data-open')));
body.querySelectorAll('[data-reply]').forEach(x=>x.onclick=()=>replyToFeed(x.getAttribute('data-reply'), x.getAttribute('data-ch'), x.getAttribute('data-name')));
body.querySelectorAll('[data-like]').forEach(x=>x.onclick=()=>{ x.textContent='♥ Liked'; x.style.color='var(--gold)'; toast('Simulated like — no live API called.'); });
}
function fmtShort(iso){ try{ return new Date(iso).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return ''; } }
function feedCard(it){
const initials=(it.author.name||'?').split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase();
const canReply=it.kind==='mentions'||it.kind==='comments'||it.kind==='dms';
return `<div class="feed-card${it.unread?' unread':''}">
<div class="fc-top">
<span class="fc-av" style="background:${it.author.color}">${initials}</span>
<span><span class="fc-name">${esc(it.author.name)}</span><br><span class="fc-handle">@${esc(it.author.handle)} · ${chanName(it.channel)}</span></span>
<span class="fc-when">${fmtShort(it.datetime)}</span>
</div>
<div class="fc-text">${esc(it.text)}</div>
<div class="fc-actions">
<span data-like>♥ ${it.likes||0}</span>
${canReply?`<span data-reply="${esc(it.id)}" data-ch="${esc(it.channel)}" data-name="${esc(it.author.handle)}">↩ Reply</span>`:`<span data-reply="${esc(it.id)}" data-ch="${esc(it.channel)}" data-name="">↗ Share to compose</span>`}
<span class="fc-sim">sim</span>
</div>
</div>`;
}
function outboundCard(it){
const when=it.datetime?fmtShort(it.datetime):(it.mode||'no schedule');
return `<div class="stream-card" data-open="${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} · ${chanName(it.channel)}</span>
</div>
</div>`;
}
// Reply / share an inbound item → opens Compose prefilled (stays a draft, simulated)
function replyToFeed(feedId, ch, handle){
state=blankLaunch();
if(ch&&ch!=='all'){ state.channelsSelected=[ch]; state.channels[ch]={body:'',media:[],fields:{},schedule:{mode:'schedule',datetime:''}}; }
state.title = handle ? `Reply to @${handle}` : 'Shared from streams';
state.global.body = handle ? `@${handle} ` : '';
activeChanTab=ch&&ch!=='all'?ch:null; activePvTab=ch&&ch!=='all'?ch:null;
showView('compose'); hydrate();
toast(handle?`Replying to @${handle} — drafted (simulated, nothing sent).`:'Shared into Compose as a draft.');
setTimeout(()=>{ const b=document.getElementById('global-body'); if(b){b.focus();b.setSelectionRange(b.value.length,b.value.length);} },60);
}
// ---- drag-to-reorder columns ----
let dragColId=null;
function wireColDrag(el){
const head=el.querySelector('.hc-head');
head.addEventListener('dragstart',e=>{ dragColId=el.dataset.id; el.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
head.addEventListener('dragend',()=>{ el.classList.remove('dragging'); document.querySelectorAll('.hoot-col').forEach(c=>c.classList.remove('dragover')); });
el.addEventListener('dragover',e=>{ if(!dragColId||dragColId===el.dataset.id) return; e.preventDefault(); el.classList.add('dragover'); });
el.addEventListener('dragleave',()=>el.classList.remove('dragover'));
el.addEventListener('drop',e=>{
e.preventDefault(); el.classList.remove('dragover');
if(!dragColId||dragColId===el.dataset.id) return;
const from=hootCols.findIndex(c=>c.id===dragColId), to=hootCols.findIndex(c=>c.id===el.dataset.id);
if(from<0||to<0) return;
const [m]=hootCols.splice(from,1); hootCols.splice(to,0,m);
dragColId=null; saveHootCols(); renderStreams();
});
}
// ---- add-stream picker (modal-rig) ----
function openStreamPicker(){
let pickCh='all', pickType='mentions';
const chOpts=`<option value="all">All accounts</option>`+CHANNELS.map(c=>`<option value="${c.id}">${esc(c.name)}${c.connected?'':' (not connected)'}</option>`).join('');
const typeCard=t=>`<div class="typeopt${t.id===pickType?' sel':''}" data-t="${t.id}"><div class="tn">${esc(TYPE_LABEL[t.id]||t.id)}</div><div class="tk">${t.kind}</div></div>`;
const bodyHtml=`<div class="picker">
<label>Account</label>
<select id="pick-ch">${chOpts}</select>
<label>Stream type</label>
<div class="typegrid" id="pick-types">${(STREAM_TYPES.length?STREAM_TYPES:Object.keys(TYPE_LABEL).map(id=>({id,kind:''}))).map(typeCard).join('')}</div>
<div id="pick-q-wrap" class="hide"><label>Keyword or #hashtag</label><input type="text" id="pick-q" placeholder="e.g. grasscloth, #DesignerWallcoverings"></div>
<div style="display:flex;gap:8px;margin-top:18px">
<button class="btn" id="pick-add" style="flex:1">Add column</button>
<button class="btn ghost" id="pick-cancel">Cancel</button>
</div>
</div>`;
const m=ModalRig.open({key:'stream-picker',title:'Add a stream',bodyHtml,width:440,height:520,bg:'var(--panel)'});
const qWrap=m.body.querySelector('#pick-q-wrap');
const syncQ=()=>qWrap.classList.toggle('hide',pickType!=='search');
m.body.querySelector('#pick-ch').onchange=e=>pickCh=e.target.value;
m.body.querySelectorAll('.typeopt').forEach(o=>o.onclick=()=>{
pickType=o.getAttribute('data-t');
m.body.querySelectorAll('.typeopt').forEach(x=>x.classList.toggle('sel',x===o));
syncQ();
});
syncQ();
m.body.querySelector('#pick-cancel').onclick=()=>m.close();
m.body.querySelector('#pick-add').onclick=()=>{
const q=(m.body.querySelector('#pick-q')||{}).value||'';
hootCols.push({id:cid(),channel:pickCh,type:pickType,q:pickType==='search'?q.trim():''});
saveHootCols(); m.close(); renderStreams();
toast(`Added ${TYPE_LABEL[pickType]} column.`);
};
}
// ---------- 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>
${(l.published||l.status==='published')?`<span class="muted" style="color:#5ab880">✓ Published</span>`
:`<button class="btn btn-publish" data-act="publish" data-id="${esc(l.id)}">⚡ Publish Now</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 Now posts live to connected channels.'); 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(j.note||'Published.', j.live===false); return true;
}catch(e){ toast('Publish failed: '+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.published||l.status==='published')
?`<span class="muted" style="align-self:center;color:#5ab880">✓ Published</span>`
:`<button class="btn btn-publish" id="ml-publish">⚡ Publish Now</button>`}
</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(){
// Hootsuite board — column width
const hw=document.getElementById('hoot-width');
if(hw){
const savedW=localStorage.getItem('dwlaunch:hootWidth');
if(savedW) hw.value=savedW;
const applyW=()=>document.documentElement.style.setProperty('--colw',hw.value+'px');
applyW();
hw.addEventListener('input',()=>{ applyW(); localStorage.setItem('dwlaunch:hootWidth',hw.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);
wireDensity();
init().then(()=>{
// deep-link: ?media=<query> opens the Media → Vendor IG tab pre-filtered
// (e.g. /?media=zoffany). ?source= overrides the source (default vendor).
const params=new URLSearchParams(location.search);
if(params.has('media')){
const qv=params.get('media')||'';
mlState.source=params.get('source')||'vendor';
mlState.q=qv;
showView('media');
const box=document.getElementById('ml-search'); if(box) box.value=qv;
return;
}
// restore last-used view (persisted), default Compose
const saved=localStorage.getItem('dwlaunch:view');
if(!window.__dlCompose && 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>