PageSourceSearch

https://app.replicants.ai/js/photo-star.js?v=20260819star2

js replicants.ai collected 2026-10-05 12:30:12 UTC 8,551 bytes, 198 lines download raw bytes

1/**
2 * photo-star.js — starring a STOCK photo, in one place.
3 *
4 * ── Why this file exists, and why it is not in the library picker ────────
5 * 🔻 ALEX, 2026-08-19: *"this favoriting shouldn't be appearing here. This
6 * should be appearing on the stock library because there's no point favoriting
7 * images that you've already saved… I'm looking through the stock library. I
8 * see some images that I like but they're not right to be used right now or I
9 * want to maybe make a shortlist. I click on the star thing. I know that
10 * they've been saved in my photos."*
11 *
12 * The star was built on the wrong surface. It was on `media-picker.js` — the
13 * tenant's OWN library — where it marks a picture the coach has already gone
14 * to the trouble of saving. That is a step too late to be useful. The moment
15 * the star is FOR is the moment they are scrolling stock photos and see one
16 * they might want later.
17 *
18 * So starring a stock photo does two things at once, which is the whole point:
19 *   1. imports it into their library (`POST /api/media/import-url`), and
20 *   2. marks it a favourite,
21 * so it is waiting under My photos → ★ Favourites whenever they come back.
22 *
23 * ── One implementation, three mounts (CODING-STANDARDS §4) ───────────────
24 * The stock grid is drawn in THREE places today, because the website's Photos
25 * step still runs an inline copy of the picker that `photo-picker.js` was
26 * extracted from (see that file's header — it is a known and recorded split):
27 *
28 *   · src/public/js/photo-picker.js   — the shared picker (courses)
29 *   · src/public/js/launch-studio.js  — the website Photos step, ×2 grids
30 *
31 * Alex asked for this "wherever the stock library comes up". Writing the star
32 * three times is how the next person finds three subtly different stars, so
33 * every one of them mounts this. There is no fourth copy.
34 *
35 * ── The already-starred set ──────────────────────────────────────────────
36 * A star has to draw in its ON state for a photo they starred last week, or
37 * the shortlist is invisible the moment they reopen the picker. Stock photos
38 * carry no identity of ours, so the join is `media_files.source_url` — the
39 * column the import writes. `preload()` fetches that set once per page and
40 * caches it; a picker opening for the second time costs nothing.
41 */
42(function () {
43  'use strict';
44
45  var favUrls = null;        // Set of source_url already starred, or null
46  var loading = null;        // in-flight preload, so N pickers share one call
47
48  /* ⚠️ THREE SOURCES, THE SAME THREE js/media-picker.js READS. A picker that
49     checks only `cb_token` goes out unauthenticated wherever the token lives
50     somewhere else, takes a 401, and the star silently flips back — which is
51     indistinguishable from the real bug this file had. Kept identical to the
52     sibling picker deliberately; if a fourth source ever appears, both change. */
53  function authHeaders() {
54    try {
55      var t = (window.SharedAuth && window.SharedAuth.getToken())
56        || localStorage.getItem('cb_token')
57        || localStorage.getItem('coachbot_token');
58      if (t) return { Authorization: 'Bearer ' + t };
59    } catch (_) {}
60    return {};
61  }
62
63  function esc(s) {
64    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
65      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
66    });
67  }
68
69  /**
70   * The URL a starred photo is remembered by.
71   *
72   * ⚠️ MUST be the SAME string the import sends, or the star will not light up
73   * next time and the coach will star it twice. Both use the largest usable
74   * source, never the thumbnail — a shortlist that saves a 130px-tall preview
75   * is a shortlist of unusable pictures.
76   */
77  function keyOf(photo) {
78    if (!photo) return '';
79    if (typeof photo === 'string') return photo;
80    return (photo.src && (photo.src.large || photo.src.medium)) || photo.url || '';
81  }
82
83  function preload() {
84    if (favUrls) return Promise.resolve(favUrls);
85    if (loading) return loading;
86    loading = fetch('/api/media?favourites=1&type=image&limit=500', {
87      headers: authHeaders(), credentials: 'include',
88    })
89      .then(function (r) { return r.ok ? r.json() : { files: [] }; })
90      .then(function (d) {
91        var rows = (d && (d.files || d.media || d.rows)) || [];
92        favUrls = new Set();
93        rows.forEach(function (m) { if (m && m.source_url) favUrls.add(m.source_url); });
94        return favUrls;
95      })
96      .catch(function () { favUrls = new Set(); return favUrls; })
97      .then(function (s) { loading = null; return s; });
98    return loading;
99  }
100
101  function isOn(photo) {
102    var k = keyOf(photo);
103    return !!(k && favUrls && favUrls.has(k));
104  }
105
106  /** The button's markup, to drop inside an existing `.pho-sg` tile. */
107  function markup(photo) {
108    var k = keyOf(photo);
109    if (!k) return '';
110    var on = isOn(photo);
111    var label = on ? 'Saved to your photos' : 'Save to your photos';
112    return '<button type="button" class="pho-star' + (on ? ' on' : '') + '"' +
113      ' data-star-url="' + esc(k) + '"' +
114      ' data-star-name="' + esc((photo && photo.alt) || '') + '"' +
115      ' aria-pressed="' + (on ? 'true' : 'false') + '"' +
116      ' title="' + label + '" aria-label="' + label + '">' +
117      (on ? '★' : '☆') + '</button>';
118  }
119
120  /**
121   * Wire every star under `root`. Delegated, so a grid that repaints its tiles
122   * does not need re-wiring and cannot end up with two handlers on one star.
123   */
124  function wire(root) {
125    if (!root || root.__phoStarWired) return;
126    root.__phoStarWired = true;
127    root.addEventListener('click', function (ev) {
128      var btn = ev.target && ev.target.closest && ev.target.closest('.pho-star');
129      if (!btn || !root.contains(btn)) return;
130      // The tile itself CHOOSES the photo. The star must never also choose it,
131      // or the feature is unusable for the thing it is for.
132      ev.preventDefault();
133      ev.stopPropagation();
134      toggle(btn);
135    }, true);
136  }
137
138  function paint(btn, on, busy) {
139    btn.classList.toggle('on', on);
140    btn.textContent = on ? '★' : '☆';
141    btn.setAttribute('aria-pressed', on ? 'true' : 'false');
142    var label = on ? 'Saved to your photos' : 'Save to your photos';
143    btn.setAttribute('title', label);
144    btn.setAttribute('aria-label', label);
145    btn.disabled = !!busy;
146  }
147
148  function toggle(btn) {
149    if (btn.disabled) return;
150    var url = btn.getAttribute('data-star-url');
151    if (!url) return;
152    var on = btn.classList.contains('on');
153    // Flipped first and reverted on failure — a star is a one-tap thing, and a
154    // round trip before the pixel moves reads as the tap not landing.
155    paint(btn, !on, true);
156
157    var done = function (ok) {
158      if (ok) {
159        if (!favUrls) favUrls = new Set();
160        if (on) favUrls.delete(url); else favUrls.add(url);
161        // Every other star for the SAME photo on screen (Suggested and Stock
162        // can both be showing it) follows, or the two disagree.
163        document.querySelectorAll('.pho-star[data-star-url="' + (window.CSS && CSS.escape ? CSS.escape(url) : url) + '"]')
164          .forEach(function (b) { if (b !== btn) paint(b, !on, false); });
165        paint(btn, !on, false);
166      } else {
167        paint(btn, on, false);
168      }
169    };
170
171    if (!on) {
172      fetch('/api/media/import-url', {
173        method: 'POST',
174        credentials: 'include',
175        headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
176        body: JSON.stringify({
177          url: url,
178          name: btn.getAttribute('data-star-name') || '',
179          folder: 'root',
180          is_favourite: true,
181        }),
182      }).then(function (r) { done(r.ok); }).catch(function () { done(false); });
183      return;
184    }
185
186    // Un-starring takes it off the shortlist. It does NOT delete the picture —
187    // by then it is a saved photo like any other, and a star that deletes is a
188    // star nobody dares press.
189    fetch('/api/media/unfavourite-by-url', {
190      method: 'POST',
191      credentials: 'include',
192      headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
193      body: JSON.stringify({ url: url }),
194    }).then(function (r) { done(r.ok); }).catch(function () { done(false); });
195  }
196
197  window.PhotoStar = { preload: preload, markup: markup, wire: wire, isOn: isOn, keyOf: keyOf };
198})();

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.