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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('extension.nudge.title') }}
+
+
+ {{ t('extension.nudge.description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
@@ -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 @@
-
-
-
-
+
+
+
+
+
-
@@ -52,62 +50,63 @@
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