PageSourceSearch

https://www.orwellsbaddream.com/app-core.js

js orwellsbaddream.com collected 2026-09-24 14:49:34 UTC 20,213 bytes, 443 lines download raw bytes

1
2// Init Supabase from config.js (loaded via env-generated file).
3// The SDK is used only for auth flows (signInWithOAuth, signOut,
4// onAuthStateChange). Data queries go through `db` below, which calls
5// PostgREST directly — this is the same HTTP API the SDK wraps.
6const SUPABASE_STORAGE_KEY = "sb-eyjmtwlqscbkjpgvkdnt-auth-token";
7
8const sb = supabase.createClient(SUPABASE_URL, SUPABASE_KEY, {
9  auth: {
10    detectSessionInUrl: true,
11    flowType: "pkce",
12    persistSession: true,
13    storage: window.localStorage,
14  },
15});
16
17function readSession() {
18  try {
19    const raw = localStorage.getItem(SUPABASE_STORAGE_KEY);
20    return raw ? JSON.parse(raw) : null;
21  } catch { return null; }
22}
23function authToken() { return readSession()?.access_token || null; }
24
25// Fluent PostgREST query builder. Mirrors sb.from() shape so call sites read
26// the same. Chain methods build up state; awaiting the builder runs the query.
27function pgQuery(table) {
28  let method = "GET";
29  let body = null;
30  let selectCols = "*";
31  let selectExplicit = false;
32  const filters = [];
33  let order = null;
34  let limit = null;
35  let range = null;
36  let single = false;
37  let maybeSingle = false;
38  let upsertOnConflict = null;
39
40  const builder = {
41    select(cols) { selectCols = cols || "*"; selectExplicit = true; return builder; },
42    insert(payload) { method = "POST"; body = JSON.stringify(payload); return builder; },
43    // Was missing entirely, and its absence was silent: db.from(x).upsert(...)
44    // threw "upsert is not a function", the exception escaped the async click
45    // handler that called it, and the optimistic UI stayed flipped while
46    // nothing was written. A control that lies about having saved.
47    //
48    // PostgREST spells upsert as POST + Prefer: resolution=merge-duplicates,
49    // with on_conflict naming the unique constraint to merge on.
50    upsert(payload, opts) {
51      method = "POST";
52      body = JSON.stringify(payload);
53      upsertOnConflict = (opts && opts.onConflict) || null;
54      return builder;
55    },
56    update(payload) { method = "PATCH"; body = JSON.stringify(payload); return builder; },
57    delete() { method = "DELETE"; return builder; },
58    eq(col, val) { filters.push([col, `eq.${val}`]); return builder; },
59    neq(col, val) { filters.push([col, `neq.${val}`]); return builder; },
60    in(col, vals) {
61      const list = vals.map(v => `"${String(v).replace(/"/g, '\\"')}"`).join(",");
62      filters.push([col, `in.(${list})`]);
63      return builder;
64    },
65    is(col, val) { filters.push([col, `is.${val}`]); return builder; },
66    not(col, op, val) { filters.push([col, `not.${op}.${val}`]); return builder; },
67    ilike(col, pattern) { filters.push([col, `ilike.${pattern}`]); return builder; },
68    or(exprs) { filters.push(["or", `(${exprs.join(",")})`]); return builder; },
69    order(col, opts = {}) { order = `${col}.${opts.ascending === false ? "desc" : "asc"}`; return builder; },
70    limit(n) { limit = n; return builder; },
71    range(from, to) { range = [from, to]; return builder; },
72    single() { single = true; return builder; },
73    maybeSingle() { maybeSingle = true; return builder; },
74    then(onFulfilled, onRejected) { return execute().then(onFulfilled, onRejected); },
75  };
76
77  async function execute() {
78    const params = new URLSearchParams();
79    if (method === "GET" || selectExplicit) params.set("select", selectCols);
80    for (const [col, expr] of filters) params.append(col, expr);
81    if (order) params.set("order", order);
82    if (limit != null) params.set("limit", String(limit));
83    // Names the unique constraint PostgREST merges on. Without it the POST is
84    // a plain insert and a second write raises a duplicate-key error.
85    if (upsertOnConflict) params.set("on_conflict", upsertOnConflict);
86
87    const headers = { "apikey": SUPABASE_KEY };
88    const tok = authToken();
89    if (tok) headers["Authorization"] = `Bearer ${tok}`;
90    if (body != null) headers["Content-Type"] = "application/json";
91    if (method !== "GET") {
92      const prefer = [selectExplicit ? "return=representation" : "return=minimal"];
93      if (upsertOnConflict !== null) prefer.push("resolution=merge-duplicates");
94      headers["Prefer"] = prefer.join(",");
95    }
96    if (range) { headers["Range-Unit"] = "items"; headers["Range"] = `${range[0]}-${range[1]}`; }
97    if (single) headers["Accept"] = "application/vnd.pgrst.object+json";
98
99    const url = `${SUPABASE_URL}/rest/v1/${table}?${params.toString()}`;
100    let res;
101    try {
102      res = await fetch(url, { method, headers, body });
103    } catch (e) {
104      return { data: null, error: { message: e.message || "Network error" } };
105    }
106    if (res.status === 204) return { data: null, error: null };
107
108    const text = await res.text();
109    let parsed = null;
110    if (text) {
111      try { parsed = JSON.parse(text); } catch { parsed = text; }
112    }
113    if (!res.ok) {
114      const msg = parsed?.message || parsed?.error || res.statusText || `HTTP ${res.status}`;
115      return { data: null, error: { message: msg, status: res.status, details: parsed } };
116    }
117    if (single) return { data: parsed, error: null };
118    if (maybeSingle) {
119      const rows = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);
120      return { data: rows[0] ?? null, error: null };
121    }
122    return { data: parsed, error: null };
123  }
124
125  return builder;
126}
127
128const db = {
129  from(table) { return pgQuery(table); },
130  async rpc(name, params) {
131    const headers = { "apikey": SUPABASE_KEY, "Content-Type": "application/json" };
132    const tok = authToken();
133    if (tok) headers["Authorization"] = `Bearer ${tok}`;
134    let res;
135    try {
136      res = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${name}`, {
137        method: "POST", headers, body: JSON.stringify(params || {}),
138      });
139    } catch (e) {
140      return { data: null, error: { message: e.message || "Network error" } };
141    }
142    if (res.status === 204) return { data: null, error: null };
143    const text = await res.text();
144    let parsed = null;
145    if (text) {
146      try { parsed = JSON.parse(text); } catch { parsed = text; }
147    }
148    if (!res.ok) {
149      const msg = parsed?.message || parsed?.error || `HTTP ${res.status}`;
150      return { data: null, error: { message: msg, status: res.status, details: parsed } };
151    }
152    return { data: parsed, error: null };
153  },
154};
155
156let currentUser = null;
157let currentProfile = null;
158// URL routing: every page maps to a real URL so links are shareable,
159// bookmarkable, and browser back/forward actually works. The routing is
160// best-effort — state still lives in module-level vars (currentTeamId,
161// currentProfile_*). URL is just a reflection we keep in sync here.
162
163const PUBLIC_PAGES = new Set(["landing", "install", "terms", "privacy", "privacy-profile-data"]);
164let pendingPostAuthPath = null;   // stashed when a logged-out visitor hits a private URL
165let pendingJoinCode = null;       // stashed from legacy #join/<code> during boot
166
167function pathForPage(page, extra) {
168  if (page === "dashboard") return "/home";
169  if (page === "team") {
170    const tid = typeof extra === "string" ? extra : extra?.teamId;
171    if (!tid) return "/home";
172    // Each workspace view is its own address, the way a list is — People
173    // included, so the three read alike in the bar. Bare /team/<id> still
174    // resolves to People, for old links and for landing on the workspace.
175    const view = (typeof extra === "object" && extra) ? extra.view : null;
176    const seg = view === "integrations" ? "configure"
177              : view === "team" ? "team"
178              : view === "people" ? "people"
179              : view === "notifications" ? "notifications"
180              : null;
181    return seg ? `/team/${encodeURIComponent(tid)}/${seg}` : `/team/${encodeURIComponent(tid)}`;
182  }
183  if (page === "profile") {
184    const tid = extra?.teamId || currentProfile_TeamId;
185    const pid = extra?.profileId || currentProfile_Id;
186    if (!tid || !pid) return "/home";
187    return `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`;
188  }
189  if (page === "review") {
190    const tid = extra?.teamId;
191    const iid = extra?.integrationId;
192    const eid = extra?.eventId;
193    if (!tid || !iid) return "/home";
194    const base = `/team/${encodeURIComponent(tid)}/review/${encodeURIComponent(iid)}`;
195    return eid ? `${base}/event/${encodeURIComponent(eid)}` : base;
196  }
197  if (page === "entries") {
198    const tid = extra?.teamId || currentTeamId;
199    if (!tid) return "/home";
200    let p = `/team/${encodeURIComponent(tid)}/entries`;
201    if (extra?.listId) {
202      p += `/${encodeURIComponent(extra.listId)}`;
203      if (extra.view === "outreach") p += "/outreach";   // pipeline is the default sub-view
204    }
205    return p;
206  }
207  if (page === "settings") return "/account";
208  if (page === "oauth-consent") return "/oauth/consent";
209  if (page === "install") return "/install";
210  if (page === "terms") return "/terms";
211  if (page === "privacy") return "/privacy";
212  if (page === "privacy-profile-data") return "/privacy/profile-data";
213  return "/";
214}
215
216function pageFromPath(rawPath) {
217  const path = rawPath.replace(/\/+$/, "") || "/";
218  if (path === "/" || path === "") return { page: "landing" };
219  if (path === "/home") return { page: "dashboard" };
220  // /dashboard was this page's address until it was renamed. Old links and
221  // bookmarks still land, and navigate() rewrites the bar to /home.
222  if (path === "/dashboard") return { page: "dashboard" };
223  if (path === "/account") return { page: "settings" };
224  // /settings was this page's address until it was renamed — and the team's
225  // own Configure view now owns the word "settings" in this app. Old links
226  // still land, and navigate() rewrites the bar to /account.
227  if (path === "/settings") return { page: "settings" };
228  if (path === "/oauth/consent") return { page: "oauth-consent" };
229  if (path === "/install") return { page: "install" };
230  if (path === "/terms") return { page: "terms" };
231  if (path === "/privacy") return { page: "privacy" };
232  if (path === "/privacy/profile-data") return { page: "privacy-profile-data" };
233  let m = path.match(/^\/team\/([^/]+)\/profile\/([^/]+)$/);
234  if (m) return { page: "profile", teamId: decodeURIComponent(m[1]), profileId: decodeURIComponent(m[2]) };
235  m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)\/event\/([^/]+)$/);
236  if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]), eventId: decodeURIComponent(m[3]) };
237  m = path.match(/^\/team\/([^/]+)\/review\/([^/]+)$/);
238  if (m) return { page: "review", teamId: decodeURIComponent(m[1]), integrationId: decodeURIComponent(m[2]) };
239  m = path.match(/^\/team\/([^/]+)\/entries(?:\/([^/]+)(\/outreach)?)?$/);
240  if (m) return {
241    page: "entries",
242    teamId: decodeURIComponent(m[1]),
243    listId: m[2] ? decodeURIComponent(m[2]) : null,
244    view: m[3] ? "outreach" : (m[2] ? "pipeline" : null),
245  };
246  m = path.match(/^\/team\/([^/]+)\/(people|configure|team|notifications)$/);
247  if (m) return {
248    page: "team",
249    teamId: decodeURIComponent(m[1]),
250    view: m[2] === "configure" ? "integrations" : m[2],
251  };
252  m = path.match(/^\/team\/([^/]+)$/);
253  if (m) return { page: "team", teamId: decodeURIComponent(m[1]) };
254  return { page: "landing" };
255}
256
257// Legacy hash deep-links (#privacy, #terms, #profile/TID/PID, #join/CODE, #login)
258// shipped in the first version of the site — translate to paths so the rest of
259// the routing layer doesn't have to think about hashes. Runs synchronously before
260// auth resolves, which is fine because we only rewrite the URL; actual navigation
261// happens inside renderRoute / onAuth.
262function migrateLegacyHash() {
263  const h = window.location.hash;
264  if (!h) return;
265  if (h === "#privacy") { history.replaceState(null, "", "/privacy"); return; }
266  if (h === "#terms")   { history.replaceState(null, "", "/terms");   return; }
267  if (h.startsWith("#profile/")) {
268    const parts = h.slice("#profile/".length).split("/");
269    const tid = decodeURIComponent(parts[0] || "");
270    const pid = decodeURIComponent(parts[1] || "");
271    if (tid && pid) {
272      history.replaceState(null, "", `/team/${encodeURIComponent(tid)}/profile/${encodeURIComponent(pid)}`);
273    }
274    return;
275  }
276  if (h.startsWith("#join/")) {
277    pendingJoinCode = h.slice("#join/".length);
278    history.replaceState(null, "", window.location.pathname);
279    return;
280  }
281  // #login and OAuth hash params (#access_token=...) are left for the existing
282  // handlers at onAuthStateChange / line 5717 to consume.
283}
284
285let _settingsReturn = { page: "dashboard" };
286function goBackFromSettings() {
287  if (_settingsReturn && _settingsReturn.page === "team" && _settingsReturn.teamId) navigate("team", _settingsReturn.teamId);
288  else navigate("dashboard");
289}
290
291function navigate(page, extra, opts = {}) {
292  // Reveal the body — the inline script in <head> hid it while we
293  // waited for Supabase to resolve the stored session. Once we've
294  // picked a page to show, no more flicker risk.
295  document.documentElement.classList.remove("auth-pending");
296  // Remember where Settings was opened from so its back link returns there:
297  // a team/workspace page → that team; anywhere else → the dashboard (home).
298  if (page === "settings") {
299    const prevId = document.querySelector(".page.active")?.id || "";
300    _settingsReturn = ((prevId === "page-team" || prevId === "page-entries" || prevId === "page-profile") && typeof currentTeamId !== "undefined" && currentTeamId)
301      ? { page: "team", teamId: currentTeamId }
302      : { page: "dashboard" };
303  }
304  document.querySelectorAll(".page").forEach(p => p.classList.remove("active"));
305  const target = document.getElementById(`page-${page}`);
306  if (target) target.classList.add("active");
307
308  document.body.classList.toggle("on-profile", page === "profile");
309  // Clear CSV import status when leaving the team page
310  const csvStatus = document.getElementById("csv-status");
311  if (csvStatus) csvStatus.innerHTML = "";
312
313  // Sync the URL to reflect the page we just rendered. For profile we synthesize
314  // the extra from the globals openProfile already set (keeps every existing
315  // caller of navigate("profile") working unchanged).
316  if (!opts.skipHistory) {
317    let extraForPath = extra;
318    if (page === "profile" && !extra) {
319      extraForPath = { teamId: currentProfile_TeamId, profileId: currentProfile_Id };
320    }
321    const targetPath = pathForPage(page, extraForPath);
322    const currentPath = window.location.pathname + window.location.search;
323    if (targetPath !== currentPath) {
324      if (opts.replace) history.replaceState({ page }, "", targetPath);
325      else              history.pushState({ page }, "", targetPath);
326    }
327  }
328
329  if (page === "dashboard") loadDashboard();
330  if (page === "team" && extra) {
331    if (typeof extra === "object" && extra.view && typeof _pendingTeamView !== "undefined") {
332      _pendingTeamView = extra.view;
333      // Switch the view NOW, not when the team data lands. loadTeamDetail is
334      // several round trips long and only set the view at the end of it, so
335      // #page-team came up showing whichever view you were last on — press
336      // People from Configure and you watched Configure for a second first.
337      if (typeof setTeamView === "function") setTeamView(extra.view, { syncUrl: false });
338    }
339    loadTeamDetail(typeof extra === "string" ? extra : extra.teamId);
340  }
341  if (page === "settings") loadSettings();
342  if (page === "oauth-consent") loadOAuthConsent();
343  if (page === "profile") loadProfile();
344  if (page === "review") {
345    if (extra && typeof extra === "object") {
346      if (extra.teamId) currentReview_TeamId = extra.teamId;
347      if (extra.integrationId) currentReview_IntegrationId = extra.integrationId;
348      currentReview_EventId = extra.eventId || null;
349    }
350    loadReview();
351  }
352  if (page === "entries" && !opts.skipLoad) {
353    const tid = (extra && typeof extra === "object" ? extra.teamId : extra) || currentTeamId;
354    const lid = (extra && typeof extra === "object") ? extra.listId : null;
355    const view = (extra && typeof extra === "object") ? extra.view : null;
356    loadEntriesPage(tid, lid, view);
357  }
358  // Show/hide the workspace sidebar (team + pipeline pages) and offset content.
359  if (typeof updateWorkspaceChrome === "function") updateWorkspaceChrome(page);
360  window.scrollTo(0, 0);
361}
362
363// Resolve the current URL into a rendered page. Called on boot, on popstate,
364// and after auth completes (so a logged-in deep-link lands where it should).
365function renderRoute() {
366  const parsed = pageFromPath(window.location.pathname);
367  if (PUBLIC_PAGES.has(parsed.page)) {
368    navigate(parsed.page, undefined, { skipHistory: true });
369    return;
370  }
371  if (!currentUser) {
372    // Stash intended destination so onAuth can restore it after sign-in.
373    // Skip `/` — that just means the user was on the home/landing page.
374    // Include the query string: extension deep-links carry ?name=<...> and
375    // losing it would blank out the profile page's name-based matching.
376    if (window.location.pathname !== "/") pendingPostAuthPath = window.location.pathname + window.location.search;
377    // Land the user on `/` (the canonical sign-in URL) — the login page
378    // shouldn't show under a /home or /team/X URL. Drop skipHistory
379    // so navigate() updates the address bar via replaceState.
380    navigate("landing", undefined, { replace: true });
381    return;
382  }
383  if (parsed.page === "team") {
384    if (parsed.view && typeof _pendingTeamView !== "undefined") _pendingTeamView = parsed.view;
385    navigate("team", { teamId: parsed.teamId, view: parsed.view || null }, { skipHistory: true });
386    // Returning from Stripe Checkout lands on /team/<id>?billing=…
387    // skipHistory above leaves the query string intact for this to read.
388    if (typeof handleBillingReturn === "function") handleBillingReturn(parsed.teamId);
389    return;
390  }
391  if (parsed.page === "profile") {
392    currentProfile_TeamId = parsed.teamId;
393    currentProfile_Id = parsed.profileId;
394    // Deep-links from the extension carry ?name=<rendered LinkedIn name> so the
395    // gsheet RPC and Luma name-matching have a display name to work with.
396    // Without it those sections render empty even when matches exist.
397    const nameParam = new URLSearchParams(window.location.search).get("name");
398    currentProfile_Name = nameParam ? nameParam.trim() || null : null;
399    // No people-list row context on a direct URL — clear so a stale
400    // preview from a previous click doesn't leak onto this profile.
401    currentProfile_ListRow = null;
402    navigate("profile", undefined, { skipHistory: true });
403    return;
404  }
405  if (parsed.page === "review") {
406    currentReview_TeamId = parsed.teamId;
407    currentReview_IntegrationId = parsed.integrationId;
408    currentReview_EventId = parsed.eventId || null;
409    navigate("review", undefined, { skipHistory: true });
410    return;
411  }
412  if (parsed.page === "entries") {
413    navigate("entries", { teamId: parsed.teamId, listId: parsed.listId, view: parsed.view }, { skipHistory: true });
414    return;
415  }
416  navigate(parsed.page, undefined, { skipHistory: true });
417}
418
419window.addEventListener("popstate", () => { renderRoute(); });
420
421// The name that gets written into `who` and shown to teammates on every
422// outreach, view, comment and marking.
423//
424// NEVER the email address. That was the last fallback everywhere — a dozen
425// copies of `full_name || linkedin_name || currentUser.email` — so anyone
426// whose profile had no name broadcast their address to the whole team on
427// every profile they opened. An email is not a display name; it is contact
428// details, and the person it belongs to did not choose to publish it here.
429//
430// user_metadata is worth trying before giving up: Google and LinkedIn both
431// put a real name there at sign-up even when the profile row has not caught
432// up yet.
433function myWhoName() {
434  const meta = (typeof currentUser !== "undefined" && currentUser && currentUser.user_metadata) || {};
435  const prof = (typeof currentProfile !== "undefined" && currentProfile) || {};
436  return (
437    (prof.full_name || "").trim() ||
438    (prof.linkedin_name || "").trim() ||
439    (meta.full_name || "").trim() ||
440    (meta.name || "").trim() ||
441    "Unknown"
442  );
443}

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.