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()">← 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.