PageSourceSearch

https://picturethewords.com/account.js?v=8

js picturethewords.com collected 2026-10-02 01:39:41 UTC 26,465 bytes, 593 lines download raw bytes

1/* account.js
2   My account / My Categories / Favorites / History -- four independent
3   entry points from the avatar dropdown, each opening the same modal
4   shell (#account-overlay / .info-box) directly to its own content, no
5   shared tab bar between them (see index.html's comment on that overlay
6   for why -- a shared tab bar meant opening any one of these surfaced
7   the other three right there too).
8   Uses global `supabaseClient`, `getCurrentUser()` (auth.js), and `CARDS`
9   (script.js). All four require login. */
10
11// All twelve directions, matching cards.json and the study-direction selector.
12// This listed only the two original pairs, so the My Account page showed two
13// rows to someone who owns all twelve -- and the "unlock a pair" prompt below
14// compared against a total of 2. The Hindi pairs had been shipping unlisted
15// here since their launch; French would have joined them.
16const PAIR_LABELS = {
17  JA_EN: 'Japanese → English',
18  HI_EN: 'Hindi → English',
19  FR_EN: 'French → English',
20};
21
22function openMyAccount() {
23  document.getElementById('account-overlay').classList.add('open');
24  renderProfileTab();
25}
26function closeMyAccount() {
27  document.getElementById('account-overlay').classList.remove('open');
28}
29
30// Each of these is a direct one-click dropdown destination, same pattern
31// as openMyNotes()/openReview() -- no tab bar, no visibility into the
32// other three sections from here.
33function openMyCategories() {
34  document.getElementById('account-overlay').classList.add('open');
35  renderCategoriesTab();
36}
37function openFavorites() {
38  document.getElementById('account-overlay').classList.add('open');
39  renderFavoritesTab();
40}
41function openHistory() {
42  document.getElementById('account-overlay').classList.add('open');
43  renderHistoryTab();
44}
45
46function accountLoginPrompt() {
47  return `<p class="notes-empty">
48    <button class="nav-info-link" style="padding:4px 0;" onclick="closeMyAccount(); openAuth();">Log in</button>
49    to see this.
50  </p>`;
51}
52
53// ── Profile tab ──────────────────────────────────────────────────
54async function renderProfileTab() {
55  const body = document.getElementById('account-tab-body');
56  body.innerHTML = '<p class="notes-empty">Loading…</p>';
57  const user = await getCurrentUser();
58  if (!user) { body.innerHTML = accountLoginPrompt(); return; }
59
60  const [{ data: profile }, { data: entitlements }] = await Promise.all([
61    supabaseClient.from('profiles').select('full_name, country').eq('id', user.id).maybeSingle(),
62    supabaseClient.from('entitlements').select('pair').eq('user_id', user.id),
63  ]);
64
65  const purchasedPairs = new Set((entitlements || []).map(e => e.pair));
66  const purchasedHtml = Object.keys(PAIR_LABELS).map(pair => {
67    const badge = purchasedPairs.has(pair)
68      ? '<span class="yes-badge">Unlocked</span>'
69      : `<span class="yes-badge">${typeof unlockedCountForPair === 'function' ? unlockedCountForPair(pair) : 0}/200</span>`;
70    return `<div class="pair-row"><span>${PAIR_LABELS[pair]}</span>${badge}</div>`;
71  }).join('') + (purchasedPairs.size < Object.keys(PAIR_LABELS).length
72    ? '<p class="notes-empty" style="padding:8px 0;">See <a href="pricing.html">Pricing</a> to unlock a pair fully.</p>'
73    : '');
74
75  body.innerHTML = `
76    <label class="settings-label">Email</label>
77    <input type="text" value="${user.email}" disabled style="width:100%;">
78    <label class="settings-label">Name</label>
79    <input type="text" id="profile-name-input" value="${(profile && profile.full_name) || ''}" style="width:100%;">
80    <label class="settings-label">Country</label>
81    <input type="text" id="profile-country-input" value="${(profile && profile.country) || ''}" style="width:100%;">
82    <button class="btn-primary" style="margin-top:10px;" onclick="saveProfile()">Save</button>
83    <p id="profile-save-status" style="font-size:12px; margin-top:4px;"></p>
84
85    <h3 style="margin-top:20px; font-size:14px;">Your language pairs</h3>
86    ${purchasedHtml}
87  `;
88}
89
90async function saveProfile() {
91  const user = await getCurrentUser();
92  if (!user) return;
93  const fullName = document.getElementById('profile-name-input').value.trim();
94  const country = document.getElementById('profile-country-input').value.trim();
95  const status = document.getElementById('profile-save-status');
96  status.textContent = 'Saving…';
97  const { error } = await supabaseClient.from('profiles').upsert({
98    id: user.id, full_name: fullName, country, updated_at: new Date().toISOString(),
99  });
100  status.textContent = error ? 'Could not save.' : 'Saved.';
101}
102
103// ── My Categories tab ────────────────────────────────────────────
104// Two states: the list of categories (default), and a single category's
105// card grid (openCategoryDetail). The list view previously had no way to
106// actually open a category's cards at all -- the count rendered, but
107// nothing was clickable and no detail view existed to click through to.
108let currentCategoryDetail = null; // { id, name } while viewing one category
109
110async function renderCategoriesTab() {
111  currentCategoryDetail = null;
112  const body = document.getElementById('account-tab-body');
113  body.innerHTML = '<p class="notes-empty">Loading…</p>';
114  const user = await getCurrentUser();
115  if (!user) { body.innerHTML = accountLoginPrompt(); return; }
116
117  const { data: categories, error } = await supabaseClient
118    .from('custom_categories')
119    .select('id, name, custom_category_cards(concept_id)')
120    .eq('user_id', user.id)
121    .order('created_at', { ascending: false });
122
123  const listHtml = (!error && categories && categories.length)
124    ? categories.map(c => `
125        <div class="notes-list-item" onclick="openCategoryDetail('${c.id}', '${escapeHtml(String(c.name).replace(/\\/g, '\\\\').replace(/'/g, "\\'"))}')">
126          <div class="notes-list-item-body">
127            <div class="notes-list-item-title">${c.name}</div>
128            <div class="notes-list-item-text">${(c.custom_category_cards || []).length} card${(c.custom_category_cards || []).length === 1 ? '' : 's'}</div>
129          </div>
130          <button class="list-remove-btn" onclick="event.stopPropagation();
130 deleteCategory('${c.id}')">Delete</button>
131        </div>`).join('')
132    : '<p class="notes-empty">No categories yet — create one below, or add a card to a new category from any flashcard\'s detail view.</p>';
133
134  body.innerHTML = `
135    ${listHtml}
136    <div style="display:flex; gap:8px; margin-top:14px;">
137      <input type="text" id="new-category-name" placeholder="New category name (e.g. Naruto words)" style="flex:1;">
138      <button class="btn-primary" onclick="handleCreateCategory()">Create</button>
139    </div>
140  `;
141}
142
143async function openCategoryDetail(categoryId, name) {
144  currentCategoryDetail = { id: categoryId, name };
145  const body = document.getElementById('account-tab-body');
146  body.innerHTML = '<p class="notes-empty">Loading…</p>';
147
148  const { data: rows, error } = await supabaseClient
149    .from('custom_category_cards')
150    .select('concept_id')
151    .eq('category_id', categoryId);
152
153  const conceptIds = (!error && rows) ? rows.map(r => String(r.concept_id)) : [];
154  const concepts = conceptIds.map(id => CARDS.find(c => c.concept_id === id)).filter(Boolean);
155
156  const gridHtml = concepts.length
157    ? `<div class="deck">${concepts.map(c => renderConceptTile(c, `removeCardFromCategory('${categoryId}','${c.concept_id}')`)).join('')}</div>`
158    : '<p class="notes-empty">No cards in this category yet — add one from any flashcard\'s detail view.</p>';
159
160  body.innerHTML = `
161    <button class="nav-info-link" style="padding:4px 0; margin-bottom:10px;" onclick="renderCategoriesTab()">&larr; My Categories</button>
162    <h3 style="font-size:14px; margin-bottom:10px;">${name}</h3>
163    ${gridHtml}
164  `;
165  applyStarDisplay();
166}
167
168// Takes one card off a category without deleting the category itself or
169// touching that card's global favourite/review state -- previously the
170// only removal available here was deleteCategory(), which wipes the
171// whole category and every card in it.
172async function removeCardFromCategory(categoryId, conceptId) {
173  await supabaseClient.from('custom_category_cards').delete()
174    .eq('category_id', categoryId).eq('concept_id', Number(conceptId));
175  if (currentCategoryDetail && currentCategoryDetail.id === categoryId) {
176    await openCategoryDetail(categoryId, currentCategoryDetail.name);
177  }
178}
179
180// ── Favorites tab ─────────────────────────────────────────────────
181// One consolidated list of every starred card, not split by category --
182// previously the only way to see favorites was per-category (the
183// stars-only filter on each category's own page), so seeing everything
184// starred meant visiting every category individually. `starred` (script.js)
185// already carries the full cross-category set, synced with the account;
186// this just renders all of it in one place, ordered the same way the
187// site's own categories are ordered everywhere else.
188async function renderFavoritesTab() {
189  currentCategoryDetail = null;
190  const body = document.getElementById('account-tab-body');
191  body.innerHTML = '<p class="notes-empty">Loading…</p>';
192
193  const orderFor = id => {
194    const cat = CATEGORIES.find(c => c.id === id);
195    return cat ? cat.order : 999;
196  };
197  const concepts = [...starred]
198    .map(id => CARDS.find(c => c.concept_id === id))
199    .filter(Boolean)
200    .sort((a, b) => orderFor(a.category_id) - orderFor(b.category_id) || a.sort_ja.localeCompare(b.sort_ja));
201
202  const gridHtml = concepts.length
203    ? `<div class="deck">${concepts.map(c => renderConceptTile(c, `removeFavorite('${c.concept_id}')`)).join('')}</div>`
204    : '<p class="notes-empty">No favorites yet — tap the star on any flashcard to add it here.</p>';
205
206  body.innerHTML = `
207    <h3 style="font-size:14px; margin-bottom:10px;">Favorites (${concepts.length})</h3>
208    ${gridHtml}
209  `;
210  applyStarDisplay();
211}
212
213// Same effect as un-starring the card (they ARE the same underlying
214// state -- Favorites is just every starred card in one place), but also
215// re-renders this list so the tile actually disappears immediately.
216// toggleStar() alone only flips that one tile's star icon in place; the
217// card would otherwise sit in the Favorites grid, looking unstarred,
218// until the list was closed and reopened.
219async function removeFavorite(conceptId) {
220  starred.delete(conceptId);
221  saveStars();
222  syncStarChange(conceptId, false).catch(() => {});
223  await renderFavoritesTab();
224}
225
226async function handleCreateCategory() {
227  const input = document.getElementById('new-category-name');
228  const name = input.value.trim();
229  if (!name) return;
230  await createCategory(name);
231  input.value = '';
232  renderCategoriesTab();
233}
234
235async function createCategory(name) {
236  const user = await getCurrentUser();
237  if (!user) return null;
238  const { data, error } = await supabaseClient
239    .from('custom_categories')
240    .insert({ user_id: user.id, name })
241    .select()
242    .single();
243  if (error) { console.error('Failed to create category:', error.message); return null; }
244  return data;
245}
246
247async function deleteCategory(categoryId) {
248  await supabaseClient.from('custom_categories').delete().eq('id', categoryId);
249  renderCategoriesTab();
250}
251
252// ── "+ Add to Category" picker, used from the card detail view ────
253// Existing categories render as real tappable buttons -- typing a name by
254// hand to match one exactly (the previous prompt()-based version) was
255// slow and a single typo silently created a duplicate category instead
256// of adding to the existing one.
257let ptwAddToCategoryConceptId = null;
258let ptwAddToCategoryPair = null;
259
260async function openAddToCategoryPicker(conceptId, pair) {
261  const user = await getCurrentUser();
262  if (!user) { alert('Please log in to use custom categories.'); openAuth(); return; }
263
264  ptwAddToCategoryConceptId = conceptId;
265  ptwAddToCategoryPair = pair;
266  document.getElementById('add-category-overlay').classList.add('open');
267  const body = document.getElementById('add-category-existing');
268  body.innerHTML = '<p class="notes-empty">Loading…</p>';
269
270  const { data: categories } = await supabaseClient
271    .from('custom_categories').select('id, name').eq('user_id', user.id).order('name');
272
273  // Same two-layer escaping as openCategoryDetail's category-name onclick:
274  // JS-escape the raw name for safe embedding in a single-quoted JS string
275  // literal first, THEN HTML-escape that result for the surrounding
276  // double-quoted attribute -- doing it in the other order (or skipping
277  // either step) reintroduces the exact quote-collision bug that made
278  // clicking a category silently do nothing.
279  const jsAndHtmlSafe = s => escapeHtml(String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"));
280  body.innerHTML = (categories && categories.length)
281    ? categories.map(c => `<button class="category-pick-btn" onclick="handleAddToExistingCategory('${c.id}', '${jsAndHtmlSafe(c.name)}')">${escapeHtml(c.name)}</button>`).join('')
282    : '<p class="notes-empty">No categories yet — create one below.</p>';
283}
284
285function closeAddToCategoryPicker() {
286  document.getElementById('add-category-overlay').classList.remove('open');
287  document.getElementById('add-category-new-name').value = '';
288  ptwAddToCategoryConceptId = null;
289  ptwAddToCategoryPair = null;
290}
291
292// Draws from the SAME shared pool as Add-to-Review and Add-to-Notes (see
293// entitlement.js's ensureUnlocked) -- previously category-add had zero
294// gating at all.
295async function handleAddToExistingCategory(categoryId, name) {
296  const conceptId = ptwAddToCategoryConceptId;
297  const pair = ptwAddToCategoryPair;
298  if (!conceptId) return;
299  const user = await getCurrentUser();
300  // A card the user made themselves (custom-cards.js) isn't curated
301  // content, so adding it to one of their own categories was never meant
302  // to draw from the shared 200-item free-tier pool.
303  const isCustom = typeof isCustomCardId === 'function' && isCustomCardId(conceptId);
304  const gate = isCustom ? { ok: true } : await ensureUnlocked(user.id, conceptId, pair, 'category');
305  if (!gate.ok) { closeAddToCategoryPicker(); alert('🔒 Unlock this pair to add more cards — see Pricing.'); return; }
306
307  const { error } = await supabaseClient
308    .from('custom_category_cards')
309    .upsert({ category_id: categoryId, concept_id: Number(conceptId) }, { onConflict: 'category_id,concept_id' });
310  closeAddToCategoryPicker();
311  if (error) { console.error('Failed to add card to category:', error.message);
311 alert('Could not add to category.'); return; }
312  alert(`Added to "${name}".`);
313}
314
315async function handleAddToNewCategory() {
316  const conceptId = ptwAddToCategoryConceptId;
317  const pair = ptwAddToCategoryPair;
318  const input = document.getElementById('add-category-new-name');
319  const name = input.value.trim();
320  if (!conceptId || !name) return;
321
322  const user = await getCurrentUser();
323  const isCustom = typeof isCustomCardId === 'function' && isCustomCardId(conceptId);
324  const gate = isCustom ? { ok: true } : await ensureUnlocked(user.id, conceptId, pair, 'category');
325  if (!gate.ok) { closeAddToCategoryPicker(); alert('🔒 Unlock this pair to add more cards — see Pricing.'); return; }
326
327  const target = await createCategory(name);
328  if (!target) { alert('Could not create category.'); return; }
329
330  const { error } = await supabaseClient
331    .from('custom_category_cards')
332    .upsert({ category_id: target.id, concept_id: Number(conceptId) }, { onConflict: 'category_id,concept_id' });
333  closeAddToCategoryPicker();
334  if (error) { console.error('Failed to add card to category:', error.message); alert('Could not add to category.'); return; }
335  alert(`Added to "${target.name}".`);
336}
337
338// ── History tab ───────────────────────────────────────────────────
339async function recordLookup(conceptId) {
340  const user = await getCurrentUser();
341  if (!user) return; // history is account-tied; silently skip when logged out
342  await supabaseClient.from('lookup_history').upsert({
343    user_id: user.id,
344    concept_id: Number(conceptId),
345    last_looked_up_at: new Date().toISOString(),
346  }, { onConflict: 'user_id,concept_id' });
347}
348
349async function renderHistoryTab() {
350  const body = document.getElementById('account-tab-body');
351  body.innerHTML = '<p class="notes-empty">Loading…</p>';
352  const user = await getCurrentUser();
353  if (!user) { body.innerHTML = accountLoginPrompt(); return; }
354
355  const { data: rows, error } = await supabaseClient
356    .from('lookup_history')
357    .select('concept_id, last_looked_up_at')
358    .eq('user_id', user.id)
359    .order('last_looked_up_at', { ascending: false })
360    .limit(50);
361
362  if (error || !rows || !rows.length) {
363    body.innerHTML = '<p class="notes-empty">No lookups yet — words you search for will show up here.</p>';
364    return;
365  }
366
367  body.innerHTML = rows.map(row => {
368    const concept = CARDS.find(c => String(c.concept_id) === String(row.concept_id));
369    if (!concept) return '';
370    const dir = firstAvailableDirection(concept);
371    const thumb = concept.directions[dir];
372    const label = labelFor(concept);
373    return `
374      <div class="notes-list-item" onclick="openConcept('${concept.concept_id}')">
375        ${thumb ? `<img src="${thumb.image_url}" alt="${label}">` : ''}
376        <div class="notes-list-item-body">
377          <div class="notes-list-item-title">${label}</div>
378        </div>
379        <button class="list-remove-btn" onclick="event.stopPropagation(); removeHistoryEntry('${concept.concept_id}')">Remove</button>
380      </div>`;
381  }).join('');
382}
383
384async function removeHistoryEntry(conceptId) {
385  const user = await getCurrentUser();
386  if (!user) return;
387  await supabaseClient.from('lookup_history').delete()
388    .eq('user_id', user.id).eq('concept_id', Number(conceptId));
389  await renderHistoryTab();
390}
391
392/* ── Offline access ───────────────────────────────────────────────
393   The fifth entry point into the same #account-overlay shell, opened by
394   index.html's "Offline" topbar button. This block was built 2026-09-04
395   in dist_site_hindi_test/ and never copied across during the Sept 7
396   sync -- the same gap that left reader.html/bookmarklet.html/
397   mobile-lookup.js/offline-download.js behind (recovered 2026-09-10),
398   except this one was missed in that recovery: offline-download.js (the
399   engine) and index.html's button both shipped, but the six functions
400   below did not, so the live button called an undefined
401   openOfflineAccess() and did nothing at all. Restored verbatim from
402   dist_site_hindi_test/account.js; the engine it drives
403   (window.offlineDownloadManager) is byte-identical between the two
404   folders, and every DOM id and CSS class this needs already exists in
405   the live shell. */
406function openOfflineAccess() {
407  document.getElementById('account-overlay').classList.add('open');
408  renderOfflineTab();
409}
410
411async function renderOfflineTab() {
412  const body = document.getElementById('account-tab-body');
413  body.innerHTML = '<p class="notes-empty">Loading offline status...</p>';
414
415  // Initialize offline manager if not ready
416  const manager = window.offlineDownloadManager;
417  if (!manager.manifest) {
418    await manager.init();
419  }
420
421  if (!manager.manifest) {
422    body.innerHTML = `
423      <h3 style="margin-bottom:14px;">Offline Access</h3>
424      <p class="notes-empty">Could not load offline manifest. Make sure you're connected to download categories for offline use.</p>
425    `;
426    return;
427  }
428
429  // Get storage quota
430  const quota = await manager.checkStorageQuota();
431  const persisted = await manager.isPersisted();
432  const iosWarning = manager.getIOSWarning();
433
434  // Get category statuses
435  const statuses = manager.getAllCategoryStatuses();
436  const downloadSize = manager.getTotalDownloadSize();
437  const overall = manager.getOverallProgress();
438
439  // Build category list HTML
440  const categoryListHtml = Object.keys(statuses).map(catId => {
441    const cat = statuses[catId];
442    const statusBadge = cat.status === 'downloaded'
443      ? '<span class="yes-badge" style="background:#1a7a4c;color:#fff;">Downloaded</span>'
444      : cat.status === 'downloading'
445        ? `<span class="yes-badge" style="background:#a87c1a;color:#fff;">Downloading ${cat.progress}%</span>`
446        : '<span class="yes-badge" style="background:#7a9aad;color:#fff;">Not downloaded</span>';
447
448    const actionBtn = cat.status === 'downloaded'
449      ? `<button class="list-remove-btn" onclick="event.stopPropagation(); removeOfflineCategory('${catId}')">Remove</button>`
450      : cat.status === 'downloading'
451        ? '<span style="font-size:12px;color:#a87c1a;">In progress...</span>'
452        : `<button class="btn-primary" style="padding:6px 14px;font-size:12px;" onclick="event.stopPropagation(); downloadOfflineCategory('${catId}')">Download</button>`;
453
454    return `
455      <div class="notes-list-item" style="cursor:default;">
456        <div class="notes-list-item-body">
457          <div class="notes-list-item-title">${cat.label}</div>
458          <div class="notes-list-item-text">${cat.formattedSize}</div>
459        </div>
460        ${statusBadge}
461        ${actionBtn}
462      </div>
463    `;
464  }).join('');
465
466  // Storage bar
467  const storageBarWidth = quota.supported ? `${Math.min(quota.percent, 100)}%` : '0%';
468  const storageColor = quota.percent > 80 ? '#c0392b' : quota.percent > 60 ? '#a87c1a' : '#1a7a4c';
469
470  body.innerHTML = `
471    <h3 style="margin-bottom:14px;">Offline Access</h3>
472
473    ${iosWarning ? `
474      <div class="offline-ios-warning" style="background:#fff3cd;border:1px solid #ffc107;border-radius:8px;padding:12px;margin-bottom:16px;font-size:13px;">
475        <strong>${iosWarning.title}</strong>
476        <p style="margin:6px 0;">${iosWarning.message}</p>
477      </div>
478    ` : ''}
479
480    <div class="offline-storage-section" style="margin-bottom:20px;">
481      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
482        <span style="font-size:13px;font-weight:600;">Storage Usage</span>
483        ${persisted ? '<span class="yes-badge" style="background:#1a7a4c;color:#fff;font-size:10px;">Persistent</span>' : '<span class="yes-badge" style="font-size:10px;">May be cleared</span>'}
484      </div>
485      ${quota.supported ? `
486        <div style="background:#e0e0e0;border-radius:4px;height:8px;overflow:hidden;margin-bottom:4px;">
487          <div style="background:${storageColor};height:100%;width:${storageBarWidth};transition:width 0.3s;"></div>
488        </div>
489        <div style="font-size:12px;color:#7a9aad;">
490          ${quota.formattedUsage} used of ${quota.formattedQuota} (${quota.percent}%)
491        </div>
492      ` : '<p style="font-size:12px;color:#7a9aad;">Storage quota not available in this browser.</p>'}
493    </div>
494
495    <div class="offline-summary" style="display:flex;gap:16px;margin-bottom:20px;padding:12px;background:#f5f5f5;border-radius:8px;">
496      <div style="flex:1;text-align:center;">
497        <div style="font-size:20px;font-weight:700;color:var(--teal-dark);">${overall.downloaded}/${overall.total}</div>
498        <div style="font-size:11px;color:#7a9aad;">Categories downloaded</div>
499      </div>
500      <div style="flex:1;text-align:center;">
501        <div style="font-size:20px;font-weight:700;color:var(--teal-dark);">${downloadSize.formattedTotal}</div>
502        <div style="font-size:11px;color:#7a9aad;">Total size</div>
503      </div>
504      <div style="flex:1;text-align:center;">
505        <div style="font-size:20px;font-weight:700;color:var(--teal-dark);">${downloadSize.formattedRemaining}</div>
506        <div style="font-size:11px;color:#7a9aad;">
506Remaining</div>
507      </div>
508    </div>
509
510    <div style="display:flex;gap:8px;margin-bottom:16px;">
511      <button class="btn-primary" style="flex:1;" onclick="downloadAllOffline()" ${manager.downloadInProgress ? 'disabled' : ''}>
512        Download All (${downloadSize.formattedRemaining})
513      </button>
514      <button class="btn-secondary" style="flex:1;" onclick="clearAllOffline()">
515        Clear All
516      </button>
517    </div>
518
519    <h4 style="font-size:13px;margin-bottom:10px;">Categories</h4>
520    <div id="offline-category-list">
521      ${categoryListHtml}
522    </div>
523
524    <p style="font-size:11px;color:#7a9aad;margin-top:16px;">
525      Downloaded content works offline. Images and audio for downloaded categories will be available without an internet connection.
526    </p>
527  `;
528
529  // Set up listener for live updates
530  if (!window._offlineTabListener) {
531    window._offlineTabListener = manager.addListener(() => {
532      // Only re-render if the offline tab is currently visible
533      if (document.getElementById('account-overlay').classList.contains('open')) {
534        const currentTitle = body.querySelector('h3');
535        if (currentTitle && currentTitle.textContent === 'Offline Access') {
536          renderOfflineTab();
537        }
538      }
539    });
540  }
541}
542
543async function downloadOfflineCategory(categoryId) {
544  const manager = window.offlineDownloadManager;
545
546  try {
547    await manager.downloadCategory(categoryId, (progress) => {
548      // Progress updates handled by listener which re-renders the tab
549    });
550  } catch (err) {
551    alert(`Download failed: ${err.message}`);
552  }
553}
554
555async function removeOfflineCategory(categoryId) {
556  const manager = window.offlineDownloadManager;
557
558  try {
559    await manager.clearCategory(categoryId);
560  } catch (err) {
561    alert(`Could not remove: ${err.message}`);
562  }
563}
564
565async function downloadAllOffline() {
566  const manager = window.offlineDownloadManager;
567
568  if (!confirm('This will download all categories for offline use. This may take a while and use significant storage. Continue?')) {
569    return;
570  }
571
572  try {
573    await manager.downloadAll();
574    alert('All categories downloaded for offline use!');
575  } catch (err) {
576    alert(`Download failed: ${err.message}`);
577  }
578}
579
580async function clearAllOffline() {
581  const manager = window.offlineDownloadManager;
582
583  if (!confirm('This will remove all downloaded offline content. Continue?')) {
584    return;
585  }
586
587  try {
588    await manager.clearAll();
589    renderOfflineTab();
590  } catch (err) {
591    alert(`Could not clear: ${err.message}`);
592  }
593}

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.