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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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.