diff --git a/manifest.json b/manifest.json index 7554e23..b4e7a84 100644 --- a/manifest.json +++ b/manifest.json @@ -27,7 +27,7 @@ "content_scripts": [ { "matches": ["https://*.skilljar.com/*"], - "js": ["src/lib/browser-polyfill.js", "src/lib/selectors.js", "src/lib/constants.js", "src/lib/translator.js", "src/lib/youtube-subtitles.js", "src/lib/protected-terms.js", "src/lib/gemini-block.js", "src/content/content.js", "src/content/code-comments.js", "src/content/header-controls.js", "src/content/text-selection.js", "src/content/sidebar-chat.js", "src/content/keyboard-shortcuts.js"], + "js": ["src/lib/browser-polyfill.js", "src/lib/selectors.js", "src/lib/constants.js", "src/lib/translator.js", "src/lib/youtube-subtitles.js", "src/lib/protected-terms.js", "src/lib/gemini-block.js", "src/content/content.js", "src/content/banners.js", "src/content/code-comments.js", "src/content/header-controls.js", "src/content/text-selection.js", "src/content/sidebar-chat.js", "src/content/keyboard-shortcuts.js"], "css": ["src/content/content.css"], "run_at": "document_idle" } diff --git a/src/content/banners.js b/src/content/banners.js new file mode 100644 index 0000000..861a629 --- /dev/null +++ b/src/content/banners.js @@ -0,0 +1,152 @@ +/** + * SkillBridge — Banner UI + * + * Pure DOM banner registry split out of content.js. Loaded after content.js + * so it can read live language and helper state via `window._sb.t`. + * Functions attach back onto `window._sb` so call sites only change shape, + * not semantics. + * + * Term-preview stays in content.js because it needs translator state and + * FLASHCARD_COURSE_SLUGS_SORTED resolution. + */ + +(function () { + const sb = window._sb; + if (!sb) { + console.warn('[SkillBridge] banners.js loaded before content.js — _sb namespace missing'); + return; + } + + // Guard: extension auto-update / dev reload re-runs content scripts. + // Without this marker we'd attach a second listener for each event each + // time, causing N banners per fire after N reloads (mirrors the + // history.pushState __sb_wrapped__ guard in content.js). + if (sb.__bannersLoaded) return; + sb.__bannersLoaded = true; + + // Build a transient banner element and animate it in. Used for the + // five "small toast" cases below; translation progress is its own + // shape (two coordinated elements, dynamic content) and doesn't fit. + function showSimpleBanner({ id, className, role, ariaLive, labels, autoDismissMs }) { + if (document.getElementById(id)) return; + const banner = document.createElement('div'); + banner.id = id; + banner.className = className; + banner.setAttribute('role', role); + if (ariaLive) banner.setAttribute('aria-live', ariaLive); + banner.textContent = sb.t(labels); + document.body.appendChild(banner); + requestAnimationFrame(() => banner.classList.add('visible')); + if (autoDismissMs) { + setTimeout(() => { + banner.classList.remove('visible'); + setTimeout(() => banner.remove(), 300); + }, autoDismissMs); + } + } + + function showOfflineBanner() { + showSimpleBanner({ + id: 'si18n-offline-banner', + className: 'si18n-offline-banner', + role: 'status', + ariaLive: 'polite', + labels: OFFLINE_LABELS, + }); + } + + function hideOfflineBanner() { + const banner = document.getElementById('si18n-offline-banner'); + if (!banner) return; + banner.classList.remove('visible'); + setTimeout(() => banner.remove(), 300); + } + + // No auto-dismiss: refresh is the only recovery, so keep the alert visible. + window.addEventListener('skillbridge:bridgeunavailable', () => { + showSimpleBanner({ + id: 'si18n-bridge-banner', + className: 'si18n-offline-banner si18n-storage-warn', + role: 'alert', + ariaLive: 'assertive', + labels: BRIDGE_UNAVAILABLE_LABELS, + }); + }); + + document.addEventListener('skillbridge:storagequota', () => { + showSimpleBanner({ + id: 'si18n-storage-banner', + className: 'si18n-offline-banner si18n-storage-warn', + role: 'status', + ariaLive: 'polite', + labels: STORAGE_WARNING_LABELS, + autoDismissMs: 8000, + }); + }); + + function showExamBanner() { + showSimpleBanner({ + id: 'si18n-exam-banner', + className: 'si18n-exam-banner', + role: 'alert', + labels: EXAM_BANNER_LABELS, + }); + } + + function showTranslationProgress() { + let bar = document.getElementById('si18n-progress-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'si18n-progress-bar'; + bar.innerHTML = '
'; + document.body.appendChild(bar); + } else { + const fill = bar.querySelector('.si18n-progress-fill'); + if (fill) fill.style.width = '15%'; + } + let toast = document.getElementById('si18n-progress-toast'); + const label = sb.t(PROGRESS_LABELS); + if (!toast) { + toast = document.createElement('div'); + toast.id = 'si18n-progress-toast'; + toast.setAttribute('role', 'status'); + toast.setAttribute('aria-live', 'polite'); + toast.innerHTML = `${label}`; + document.body.appendChild(toast); + } else { + const span = toast.querySelector('span'); + if (span) span.textContent = label; + } + requestAnimationFrame(() => { + bar.classList.add('active'); + toast.classList.add('active'); + }); + } + + function updateTranslationProgress(pct) { + const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); + if (fill) fill.style.width = `${Math.min(pct, 95)}%`; + } + + function hideTranslationProgress() { + const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); + if (fill) fill.style.width = '100%'; + setTimeout(() => { + const bar = document.getElementById('si18n-progress-bar'); + const toast = document.getElementById('si18n-progress-toast'); + bar?.classList.remove('active'); + toast?.classList.remove('active'); + setTimeout(() => { + bar?.remove(); + toast?.remove(); + }, SKILLBRIDGE_DELAYS.PROGRESS_REMOVE); + }, SKILLBRIDGE_DELAYS.PROGRESS_HIDE); + } + + sb.showOfflineBanner = showOfflineBanner; + sb.hideOfflineBanner = hideOfflineBanner; + sb.showExamBanner = showExamBanner; + sb.showTranslationProgress = showTranslationProgress; + sb.updateTranslationProgress = updateTranslationProgress; + sb.hideTranslationProgress = hideTranslationProgress; +})(); diff --git a/src/content/content.js b/src/content/content.js index 6c96ffb..f4b67d7 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -100,7 +100,7 @@ window.addEventListener('online', () => { isOffline = false; - hideOfflineBanner(); + window._sb.hideOfflineBanner?.(); // Retry deferred offline items first, then re-apply if needed if (currentLang !== 'en' && translator && isReady) { if (_offlinePendingItems.length > 0) { @@ -119,59 +119,7 @@ window.addEventListener('offline', () => { isOffline = true; - if (currentLang !== 'en') showOfflineBanner(); - }); - - function showOfflineBanner() { - if (document.getElementById('si18n-offline-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-offline-banner'; - banner.className = 'si18n-offline-banner'; - banner.setAttribute('role', 'status'); - banner.setAttribute('aria-live', 'polite'); - banner.textContent = t(OFFLINE_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - } - - function hideOfflineBanner() { - const banner = document.getElementById('si18n-offline-banner'); - if (banner) { - banner.classList.remove('visible'); - setTimeout(() => banner.remove(), 300); - } - } - - // Bridge unavailable — Puter.js script never confirmed BRIDGE_READY. - // Persistent banner (no auto-dismiss) so users know AI features are off - // until they refresh the page. - window.addEventListener('skillbridge:bridgeunavailable', () => { - if (document.getElementById('si18n-bridge-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-bridge-banner'; - banner.className = 'si18n-offline-banner si18n-storage-warn'; - banner.setAttribute('role', 'alert'); - banner.setAttribute('aria-live', 'assertive'); - banner.textContent = t(BRIDGE_UNAVAILABLE_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - }); - - // Storage quota warning — auto-dismiss after 8 seconds - document.addEventListener('skillbridge:storagequota', () => { - if (document.getElementById('si18n-storage-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-storage-banner'; - banner.className = 'si18n-offline-banner si18n-storage-warn'; - banner.setAttribute('role', 'status'); - banner.setAttribute('aria-live', 'polite'); - banner.textContent = t(STORAGE_WARNING_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - setTimeout(() => { - banner.classList.remove('visible'); - setTimeout(() => banner.remove(), 300); - }, 8000); + if (currentLang !== 'en') window._sb.showOfflineBanner?.(); }); // Lookup helper: returns map entry for given lang, falling back to 'en' @@ -200,17 +148,6 @@ return false; } - function showExamBanner() { - if (document.getElementById('si18n-exam-banner')) return; - const banner = document.createElement('div'); - banner.id = 'si18n-exam-banner'; - banner.className = 'si18n-exam-banner'; - banner.setAttribute('role', 'alert'); - banner.textContent = t(EXAM_BANNER_LABELS); - document.body.appendChild(banner); - requestAnimationFrame(() => banner.classList.add('visible')); - } - window._sb = { get currentLang() { return currentLang; @@ -264,60 +201,6 @@ translateCodeComments: null, }; - // ============================================================ - // TRANSLATION PROGRESS INDICATOR - // ============================================================ - - function showTranslationProgress() { - let bar = document.getElementById('si18n-progress-bar'); - if (!bar) { - bar = document.createElement('div'); - bar.id = 'si18n-progress-bar'; - bar.innerHTML = ''; - document.body.appendChild(bar); - } else { - const fill = bar.querySelector('.si18n-progress-fill'); - if (fill) fill.style.width = '15%'; - } - let toast = document.getElementById('si18n-progress-toast'); - const label = t(PROGRESS_LABELS); - if (!toast) { - toast = document.createElement('div'); - toast.id = 'si18n-progress-toast'; - toast.setAttribute('role', 'status'); - toast.setAttribute('aria-live', 'polite'); - toast.innerHTML = `${label}`; - document.body.appendChild(toast); - } else { - const span = toast.querySelector('span'); - if (span) span.textContent = label; - } - requestAnimationFrame(() => { - bar.classList.add('active'); - toast.classList.add('active'); - }); - } - - function updateTranslationProgress(pct) { - const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); - if (fill) fill.style.width = `${Math.min(pct, 95)}%`; - } - - function hideTranslationProgress() { - const fill = document.querySelector('#si18n-progress-bar .si18n-progress-fill'); - if (fill) fill.style.width = '100%'; - setTimeout(() => { - const bar = document.getElementById('si18n-progress-bar'); - const toast = document.getElementById('si18n-progress-toast'); - bar?.classList.remove('active'); - toast?.classList.remove('active'); - setTimeout(() => { - bar?.remove(); - toast?.remove(); - }, SKILLBRIDGE_DELAYS.PROGRESS_REMOVE); - }, SKILLBRIDGE_DELAYS.PROGRESS_HIDE); - } - // ============================================================ // PER-LESSON TERM PREVIEW // ============================================================ @@ -669,7 +552,7 @@ updateLangClass(targetLang); // Re-detect exam page (DOM may have loaded since init) if (!isExamPage) isExamPage = detectExamPage(); - if (isExamPage && targetLang !== 'en') showExamBanner(); + if (isExamPage && targetLang !== 'en') window._sb.showExamBanner?.(); const elements = getTranslatableElements(); if (elements.length === 0) return; @@ -693,8 +576,8 @@ // Start GT for visible elements right away (skip redundant viewport check) if (gtCandidates.length > 0 && targetLang !== 'en') { - showTranslationProgress(); - updateTranslationProgress( + window._sb.showTranslationProgress?.(); + window._sb.updateTranslationProgress?.( Math.round((staticCount / (staticCount + gtCandidates.length + offscreen.length)) * 80), ); queueForGoogleTranslate(gtCandidates, targetLang, true); @@ -746,7 +629,7 @@ } else if (gtGeneration === myGeneration) { // All offscreen elements processed — queue GT candidates if (gtCandidates.length > 0 && targetLang !== 'en') { - if (prevGt === 0) showTranslationProgress(); + if (prevGt === 0) window._sb.showTranslationProgress?.(); queueForGoogleTranslate(gtCandidates, targetLang); } } @@ -888,7 +771,7 @@ } processedItems += batch.length; - updateTranslationProgress(80 + Math.round((processedItems / totalItems) * 15)); + window._sb.updateTranslationProgress?.(80 + Math.round((processedItems / totalItems) * 15)); if (gtTranslateQueue.length > 0) { await new Promise((r) => setTimeout(r, SKILLBRIDGE_DELAYS.GT_BATCH)); @@ -896,7 +779,7 @@ } } finally { gtProcessing = false; - hideTranslationProgress(); + window._sb.hideTranslationProgress?.(); pruneDetachedEntries(); // Term-preview only on full completion; on cancellation, the new @@ -1004,7 +887,7 @@ currentLang = 'en'; window._protectedTerms.resetProtectedTerms(); updateLangClass('en'); - hideTranslationProgress(); + window._sb.hideTranslationProgress?.(); originalComments.forEach((html, el) => { if (el && el.parentNode) el.innerHTML = html; });