diff --git a/frontend/components/extension_nudge/InstallExtensionBanner.vue b/frontend/components/extension_nudge/InstallExtensionBanner.vue new file mode 100644 index 0000000..a4d34ed --- /dev/null +++ b/frontend/components/extension_nudge/InstallExtensionBanner.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/components/partial/ProfileButton.vue b/frontend/components/partial/ProfileButton.vue index a7f3c77..1c9aa36 100644 --- a/frontend/components/partial/ProfileButton.vue +++ b/frontend/components/partial/ProfileButton.vue @@ -10,7 +10,7 @@
  • - +

    @@ -93,6 +93,13 @@ const profilePicture = computed(() => { return profileStore.profilePicture || '/assets/images/user.png'; }); +function onAvatarLoadError(event: Event) { + const img = event.target as HTMLImageElement; + if (img && img.src !== window.location.origin + '/assets/images/user.png') { + img.src = '/assets/images/user.png'; + } +} + function confirmSignOut() { showSignOutModal.value = false; profileStore.logout(); diff --git a/frontend/composables/useExtensionPresence.ts b/frontend/composables/useExtensionPresence.ts new file mode 100644 index 0000000..fc0b95f --- /dev/null +++ b/frontend/composables/useExtensionPresence.ts @@ -0,0 +1,33 @@ +const PROBE_TIMEOUT_MS = 500; + +// Module-scoped so every caller shares one probe per app session. +const extensionPresent = ref(null); +let initialized = false; + +export function useExtensionPresence() { + if (!initialized && import.meta.client) { + initialized = true; + + window.addEventListener('message', (event: MessageEvent) => { + if (event.source !== window) return; + const data = event.data; + if (data?.source === 'subturtle-extension' && data?.type === 'presence') { + extensionPresent.value = true; + } + }); + + // Ping the extension content script in case it loaded before our listener. + try { + window.postMessage({ source: 'subturtle-dashboard', type: 'ping' }, window.location.origin); + } catch (_e) { + // postMessage can fail in unusual sandboxed contexts — non-fatal. + } + + // Conclude "not present" if no response arrives within the probe window. + setTimeout(() => { + if (extensionPresent.value === null) extensionPresent.value = false; + }, PROBE_TIMEOUT_MS); + } + + return { extensionPresent }; +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index fc2d416..1156683 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -364,5 +364,13 @@ "activity_ready": "Ready to start", "start_activity": "Start Activity", "back_to_board": "Back to Board" + }, + "extension": { + "nudge": { + "title": "Install the Subturtle extension", + "description": "Subturtle works while you watch. Install our Chrome extension to save phrases on YouTube, Netflix, and any text page.", + "cta": "Install for Chrome", + "dismiss": "Dismiss" + } } -} \ No newline at end of file +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 7bff098..a9ac640 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -15,6 +15,7 @@ export default defineNuxtConfig({ mode: process.env.NUXT_PUBLIC_MODE, MIXPANEL_PROJECT_TOKEN: process.env.NUXT_PUBLIC_MIXPANEL_PROJECT_TOKEN, MIXPANEL_API_HOST: process.env.NUXT_PUBLIC_MIXPANEL_API_HOST, + chromeWebStoreUrl: process.env.NUXT_PUBLIC_CHROME_WEB_STORE_URL || 'https://chromewebstore.google.com/detail/PLACEHOLDER', }, }, diff --git a/frontend/pages/settings/profile.vue b/frontend/pages/settings/profile.vue index cc0be99..39cffe3 100644 --- a/frontend/pages/settings/profile.vue +++ b/frontend/pages/settings/profile.vue @@ -8,7 +8,7 @@
    - Profile Photo
    @@ -95,7 +95,7 @@ definePageMeta({ const name = ref(profileStore.userDetail?.name || ''); const email = ref(profileStore.email); -const profilePicture = ref(profileStore.profilePicture); +const profilePicture = computed(() => profileStore.profilePicture); const selectedFile = ref(null); const filePreviewUrl = ref(null); const options = [{ label: t('profile.receive-daily-practice-email-reminders'), value: 'dailyReminders' }]; @@ -145,6 +145,13 @@ const profilePhotoPreview = computed(() => { return profilePicture.value || '/assets/images/user.png'; }); +function onAvatarLoadError(event: Event) { + const img = event.target as HTMLImageElement; + if (img && img.src !== window.location.origin + '/assets/images/user.png') { + img.src = '/assets/images/user.png'; + } +} + const handleSubmit = async () => { try { isSubmitting.value = true; diff --git a/frontend/pages/statistic.vue b/frontend/pages/statistic.vue index 2e2ef94..1c39951 100644 --- a/frontend/pages/statistic.vue +++ b/frontend/pages/statistic.vue @@ -1,14 +1,12 @@ diff --git a/frontend/stores/profile.ts b/frontend/stores/profile.ts index 51b419f..462a784 100644 --- a/frontend/stores/profile.ts +++ b/frontend/stores/profile.ts @@ -10,9 +10,98 @@ export const useProfileStore = defineStore('profile', () => { // profile const userDetail = ref(); - const profilePicture = computed(() => userDetail.value?.gPicture || ''); + const profilePictureBase64 = ref(''); + const profilePicture = computed(() => profilePictureBase64.value || userDetail.value?.gPicture || ''); const email = computed(() => authUser.value?.email); + const PICTURE_CACHE_PREFIX = 'profile_picture_b64:'; + const PICTURE_FAIL_PREFIX = 'profile_picture_fail:'; + const PICTURE_FAIL_TTL_MS = 24 * 60 * 60 * 1000; + const CACHE_RESIZE_PX = 96; + const CACHE_JPEG_QUALITY = 0.85; + + type PictureCacheEntry = { url: string; dataUri: string }; + type PictureFailEntry = { url: string; ts: number }; + + function pictureCacheKey(userId: string) { + return `${PICTURE_CACHE_PREFIX}${userId}`; + } + + function pictureFailKey(userId: string) { + return `${PICTURE_FAIL_PREFIX}${userId}`; + } + + function readCachedPicture(userId: string): PictureCacheEntry | null { + try { + const raw = localStorage.getItem(pictureCacheKey(userId)); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.url === 'string' && typeof parsed.dataUri === 'string') { + return parsed; + } + } catch { + // corrupt entry — fall through to null + } + return null; + } + + function writeCachedPicture(userId: string, entry: PictureCacheEntry) { + try { + localStorage.setItem(pictureCacheKey(userId), JSON.stringify(entry)); + } catch { + // QuotaExceededError — leave ref populated for the session, skip persistence + } + } + + function isPictureMarkedFailed(userId: string, url: string): boolean { + try { + const raw = localStorage.getItem(pictureFailKey(userId)); + if (!raw) return false; + const parsed = JSON.parse(raw) as PictureFailEntry; + if (parsed?.url !== url) return false; + if (Date.now() - parsed.ts > PICTURE_FAIL_TTL_MS) { + localStorage.removeItem(pictureFailKey(userId)); + return false; + } + return true; + } catch { + return false; + } + } + + function markPictureFailed(userId: string, url: string) { + try { + localStorage.setItem(pictureFailKey(userId), JSON.stringify({ url, ts: Date.now() })); + } catch { + // ignore + } + } + + async function downloadAndCachePicture(userId: string, url: string): Promise { + const dataUri = await new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = CACHE_RESIZE_PX; + canvas.height = CACHE_RESIZE_PX; + const ctx = canvas.getContext('2d'); + if (!ctx) return reject(new Error('Canvas 2D unavailable')); + ctx.drawImage(img, 0, 0, CACHE_RESIZE_PX, CACHE_RESIZE_PX); + resolve(canvas.toDataURL('image/jpeg', CACHE_JPEG_QUALITY)); + } catch (e) { + reject(e); + } + }; + img.onerror = () => reject(new Error('Failed to load profile image')); + img.src = url; + }); + + profilePictureBase64.value = dataUri; + writeCachedPicture(userId, { url, dataUri }); + } + // subscription const isFreemium = ref(false); const isSubscriptionFetching = ref(true); @@ -48,6 +137,16 @@ export const useProfileStore = defineStore('profile', () => { } function logout() { + const userId = authUser.value?.id; + if (userId) { + try { + localStorage.removeItem(pictureCacheKey(userId)); + localStorage.removeItem(pictureFailKey(userId)); + } catch { + // ignore + } + } + profilePictureBase64.value = ''; authentication.logout(); userDetail.value = undefined; } @@ -62,7 +161,41 @@ export const useProfileStore = defineStore('profile', () => { }, }) .then((profile) => { - userDetail.value = profile; + const userId = authentication.user?.id; + const gPicture = profile?.gPicture; + const knownFailed = !!(userId && gPicture && isPictureMarkedFailed(userId, gPicture)); + + // If we already know this URL is broken, strip it before exposing the profile + // to Vue — that way ProfileButton never renders the doomed . + userDetail.value = knownFailed ? { ...profile, gPicture: '' } : profile; + + if (userId && gPicture && !knownFailed) { + const cached = readCachedPicture(userId); + if (cached && cached.url === gPicture) { + profilePictureBase64.value = cached.dataUri; + } else { + // No cache or URL drifted — clear stale ref, fire-and-forget re-download. + // UI falls back to gPicture URL until the encode completes. + profilePictureBase64.value = ''; + downloadAndCachePicture(userId, gPicture).catch(() => { + // The CORS image fetch failed — remember this URL is broken so we + // don't keep retrying, and drop gPicture from local state so + // renderers fall through to the placeholder. + markPictureFailed(userId, gPicture); + if (userDetail.value && userDetail.value.gPicture === gPicture) { + userDetail.value = { ...userDetail.value, gPicture: '' }; + } + }); + } + } else { + // No gPicture (or we just stripped it) — drop any stale cached base64 so + // ProfileButton falls through to its placeholder. + profilePictureBase64.value = ''; + if (userId && !gPicture) { + try { localStorage.removeItem(pictureCacheKey(userId)); } catch {} + } + } + return profile; }) .catch((error) => { diff --git a/server/src/modules/profile/service.ts b/server/src/modules/profile/service.ts index c64cf10..61253ea 100644 --- a/server/src/modules/profile/service.ts +++ b/server/src/modules/profile/service.ts @@ -23,8 +23,24 @@ export const updateUserProfile = async ( if (rewrite) { await profileCollection.updateOne({ refId }, { gPicture, name, timeZone }); - } else if (timeZone && !(isExist as any).timeZone) { - // Only set timezone if it's not already set (ignore browser timezone on subsequent logins) - await profileCollection.updateOne({ refId }, { $set: { timeZone } }); + return; + } + + const existing = isExist as { timeZone?: string; gPicture?: string }; + const updates: Record = {}; + + // Always refresh gPicture when Google sends a new/different URL — covers both + // the never-set backfill and the user-changed-their-Google-avatar case. + if (gPicture && gPicture !== existing.gPicture) { + updates.gPicture = gPicture; + } + + // Only set timezone if it's not already set (ignore browser timezone on subsequent logins) + if (timeZone && !existing.timeZone) { + updates.timeZone = timeZone; + } + + if (Object.keys(updates).length > 0) { + await profileCollection.updateOne({ refId }, { $set: updates }); } }; \ No newline at end of file