1/* ========================================================================= 2 features/contentHub/contentSchedulePage â the top-level "Content Schedule" 3 route (Client Management section of the sidebar). 4 5 The SAME two surfaces as the client-profile Content tab (Scheduling + 6 Sharing) â this route just supplies the client itself instead of inheriting 7 it from the profile you're inside, so content work doesn't require walking 8 Clients â open profile â Content every time. 9 10 Landing state is a CENTRED client list with a search box; picking a client 11 mounts clientContent(c) exactly as the profile tab does, and the toolbar 12 grows a client chip that returns to the list to switch. The choice is 13 remembered (localStorage) so the route re-opens where you left off, and the 14 sub-tab defaults to Scheduling on entry (the route's namesake). 15 16 Everything below the picker is the profile tab's code â clientContent / 17 contentToolbar are imported, not forked, so the two entry points can never 18 drift. The toolbar's data-cc-* buttons are routed here by app.js's delegated 19 handler, which resolves the mounted surface from either host. 20 ========================================================================= */ 21import "../../../scripts/icons.js"; // window.icon must exist before this module evaluates (bundle chunk order is not the HTML order) 22import { el, esc } from "../../shared/lib/dom.js"; 23import { clientContent, contentToolbar, contentTabState } from "./clientContentTab.js"; 24import { projLogo } from "../clients/projectCard.js"; 25 26const D = window.DATA; 27const I = window.icon; 28const LS_CLIENT = "magnet.contentSchedule.client"; 29 30// module-level so the toolbar builder, the view and app.js's click routing all 31// read the same selection (the contentTabState precedent) 32const cur = { clientId: null, picking: false }; 33try { cur.clientId = localStorage.getItem(LS_CLIENT) || null; } catch (e) {} 34 35// Entering the route lands on Scheduling (its namesake), but a sub-tab the user 36// picks WHILE here must stick â so the default applies on entry only, not on 37// every re-render (which is what a plain assignment in the view would do). 38let _tabPinned = false; 39// Fresh entry: land on Scheduling, and drop an abandoned client-switch (leaving 40// the route mid-pick shouldn't strand you on the picker next time). The tab is 41// stamped HERE â before the shell builds the toolbar â because the route's 42// toolbar has no Sharing/Scheduling switcher: if the toolbar rendered while 43// contentTabState still said "sharing" (its default, or a leftover from a 44// client profile), the route would show "New share" with no way out. 45// Deep-link: another surface (Deliverables' Planner button) opens the 46// Content Schedule already pointed at a client â no picker step. 47export function csOpenFor(clientId) { 48 cur.clientId = clientId; 49 cur.picking = false; 50 try { localStorage.setItem(LS_CLIENT, clientId); } catch (_) {} 51 contentTabState.tab = "scheduling"; 52} 53 54export function csEnterRoute() { _tabPinned = false; cur.picking = false; contentTabState.tab = "scheduling"; } 55export function csTabPinned() { _tabPinned = true; } 56 57let _root = null; 58let _renderView = () => {}; 59export function configureContentSchedule(deps) { 60 if (deps && typeof deps.renderView === "function") _renderView = deps.renderView; 61} 62 63function clientList() { return (D.clients || []).filter(c => c && !(c._raw && c._raw.meta && c._raw.meta.deactivated)); } 64export function csClient() { 65 return clientList().find(c => String(c.id) === String(cur.clientId)) || null; 66} 67 68/* ---- toolbar (shell-owned; rebuilt in place on client switch) ---- */ 69export function contentScheduleToolbar() { 70 const c = csClient(); 71 if (!c || cur.picking) return ""; 72 return `<button class="btn cs-clientchip" data-cs-switch type="button" title="Switch client"> 73 ${projLogo(c, "xs")}<span>${esc(c.name)}</span>${I("chevron-down")} 74 </button> 75 <span class="spacer"></span>${contentToolbar(c, { tabs: false })}`; 76} 77 78export function refreshContentToolbar() { 79 const tb = document.querySelector("#mainToolbar"); 80 if (tb && tb.dataset.route === "content") { 81 tb.innerHTML = contentScheduleToolbar(); 82 tb.classList.toggle("is-empty", !tb.innerHTML.trim()); 83 } 84} 85 86/* ---- the centred client picker ---- */ 87// Ambient backdrop: one fan of real client-reel frames arcing across the 88// centre (assets/content-bg/*, compressed stills â decorative only). The cards 89// overlap like a dealt hand; the picker floats over the middle of the arc. 90const FAN = [ 91 { src: "p1", who: "hydrocove", r: -24, dy: 128, ry: 22, rx: 10, sk: 2.4, z: 4 }, 92 { src: "p7", who: "blindo", r: -16.5, dy: 64, ry: 15, rx: -8, sk: -1.9, z: 6 }, 93 { src: "p2", who: "blindo", r: -8.5, dy: 22, ry: 8, rx: 7, sk: 1.2, z: 7 }, 94 { src: "p3", who: "roofing", r: 0.6, dy: -4, ry: -2, rx: -6, sk: -0.8, z: 2 }, 95 { src: "p6", who: "black deer", r: 8.8, dy: 20, ry: -7.5, rx: 9, sk: 2.1, z: 5 },
96 { src: "p8", who: "renewables", r: 17.4, dy: 68, ry: -16, rx: -7, sk: -1.3, z: 1 }, 97 { src: "p5", who: "black deer", r: 24, dy: 126, ry: -21, rx: 11, sk: 1.7, z: 3 }, 98]; 99 100// reel chrome painted over each card: the action rail down the right and the 101// identity row along the bottom, so the fan reads as a stack of reels rather 102// than photos. Avatars/handles are pulled at random from the client roster. 103const R_HEART = I("heart", 'stroke-width="1.9"'); 104const R_COMMENT = I("comment", 'stroke-width="1.9"'); 105const R_REPOST = I("repost", 'stroke-width="1.9"'); 106const R_SEND = I("send", 'stroke-width="1.9"'); 107 108// each frame is that client's own footage, so the avatar/handle must be theirs 109function clientNamed(frag) { 110 const f = String(frag || "").toLowerCase(); 111 return clientList().find(c => String(c.name || "").toLowerCase().includes(f)) || null; 112} 113function handleOf(c) { 114 return String(c.name || "").toLowerCase().replace(/\(test\)/g, "").replace(/[^a-z0-9]+/g, "").slice(0, 18); 115} 116function pickerBgHTML() { 117 const cards = FAN.map((t) => { 118 const c = clientNamed(t.who); 119 const photo = c ? ((c._raw && c._raw.meta && c._raw.meta.photo) || c._logo || "") : ""; 120 const av = photo 121 ? `<span class="cs-fan-av" style="background-image:url('${esc(photo)}')"></span>` 122 : `<span class="cs-fan-av cs-fan-av--mono" style="--lc:${c ? c.color : "#888"}">${c ? esc(c.mark || "") : ""}</span>`; 123 return `<span class="cs-fan-card" style="--r:${t.r}deg;--dy:${t.dy}px;--ry:${t.ry}deg;--rx:${t.rx}deg;--sk:${t.sk}deg;z-index:${t.z};background-image:url('/assets/content-bg/${t.src}.jpg')"> 124 <span class="cs-fan-rail">${R_HEART}${R_COMMENT}${R_REPOST}${R_SEND}</span> 125 <span class="cs-fan-id">${av}<span class="cs-fan-name">${c ? esc(handleOf(c)) : ""}</span><span class="cs-fan-follow">Follow</span></span> 126 </span>`; 127 }).join(""); 128 return `<div class="cs-bg" aria-hidden="true"><div class="cs-fan">${cards}</div></div>`; 129} 130function pickerHTML() { 131 const rows = clientList().map(c => ` 132 <button class="cs-row" data-cs-pick="${esc(c.id)}" type="button"> 133 ${projLogo(c, "sm")} 134 <span class="cs-row-name">${esc(c.name)}</span> 135 ${I("chevron-right")} 136 </button>`).join(""); 137 return `<div class="cs-picker"> 138 ${pickerBgHTML()} 139 <div class="cs-picker-card"> 140 <h2 class="cs-picker-title">Content Schedule</h2> 141 <p class="cs-picker-sub">Choose a client to open their content schedule.</p> 142 <div class="cs-search">${I("search-ai") || ""}<input class="input" type="search" placeholder="Search clients" data-cs-search autocomplete="off" spellcheck="false" /></div> 143 <div class="cs-list" data-cs-list>${rows || `<p class="cs-empty">No active clients yet.</p>`}</div> 144 </div> 145 </div>`; 146} 147 148function wirePicker(node) { 149 const input = node.querySelector("[data-cs-search]"); 150 const list = node.querySelector("[data-cs-list]"); 151 if (!input || !list) return; 152 input.addEventListener("input", () => { 153 const q = input.value.trim().toLowerCase(); 154 let shown = 0; 155 list.querySelectorAll(".cs-row").forEach(row => { 156 const hit = !q || (row.querySelector(".cs-row-name").textContent || "").toLowerCase().includes(q); 157 row.hidden = !hit; 158 if (hit) shown++; 159 }); 160 let none = list.querySelector(".cs-noresult"); 161 if (!shown && !none) list.insertAdjacentHTML("beforeend", `<p class="cs-empty cs-noresult">No clients match â${esc(input.value.trim())}â.</p>`); 162 else if (none && shown) none.remove(); 163 else if (none && !shown) none.textContent = `No clients match â${input.value.trim()}â.`; 164 }); 165 // Enter picks the only remaining match â keyboard-first switching 166 input.addEventListener("keydown", e => { 167 if (e.key !== "Enter") return; 168 const vis = [...list.querySelectorAll(".cs-row")].filter(r => !r.hidden); 169 if (vis.length === 1) vis[0].click(); 170 }); 171 requestAnimationFrame(() => { try { input.focus({ preventScroll: true }); } catch (_) {} }); 172} 173 174/* ---- route view ---- */ 175export function viewContentSchedule() { 176 if (!cur.picking && !_tabPinned) contentTabState.tab = "scheduling"; 177 const node = el(`<div class="content-schedule view-enter"></div>`); 178 _root = node; 179 180 const c = csClient(); 181 if (!c || cur.picking) { 182 node.innerHTML = pickerHTML(); 183 wirePicker(node); 184 node.addEventListener("click", e => { 185 const pick = e.target.closest("[data-cs-pick]"); 186 if (!pick) return; 187 cur.clientId = pick.dataset.csPick; 188 cur.picking = false;
189 try { localStorage.setItem(LS_CLIENT, cur.clientId); } catch (_) {} 190 contentTabState.tab = "scheduling"; 191 _renderView("content"); 192 refreshContentToolbar(); 193 }); 194 return node; 195 } 196 197 node.appendChild(clientContent(c)); 198 return node; 199} 200 201/* Toolbar clicks that belong to this route (delegated from the shell, since the 202 toolbar lives outside the view node). Returns true when consumed. */ 203export function handleContentScheduleClick(e) { 204 if (e.target.closest("[data-cs-switch]")) { 205 cur.picking = true; 206 _renderView("content"); 207 refreshContentToolbar(); 208 return true; 209 } 210 return false; 211} 212 213// the mounted Content surface on THIS route (app.js resolves either host) 214export function contentScheduleNode() { 215 return _root ? _root.querySelector(".content-hub") : null; 216}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.