PageSourceSearch

https://pipeline-cms.onrender.com/src/features/contentHub/contentSchedulePage.js

js pipeline-cms.onrender.com collected 2026-10-03 11:43:12 UTC 10,276 bytes, 216 lines download raw bytes

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.