Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions frontend/components/extension_nudge/InstallExtensionBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<template>
<section v-if="isVisible" class="w-full">
<Card
class="border border-pink-200/50 bg-gradient-to-r from-pink-100 via-purple-50 to-blue-100 shadow-none backdrop-blur-sm transition-all duration-300 dark:border-purple-500/30 dark:from-pink-900/20 dark:via-purple-900/30 dark:to-blue-900/20"
>
<div class="flex items-center gap-4">
<div class="flex flex-1 items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-pink-200 to-purple-300 shadow-inner dark:from-pink-800 dark:to-purple-700"
>
<Icon name="IconBolt" class="h-5 w-5 text-purple-700 dark:text-purple-200" />
</div>
<div class="flex-1">
<div
class="bg-gradient-to-r from-purple-700 to-blue-600 bg-clip-text text-sm font-semibold text-transparent dark:from-purple-300 dark:to-blue-300"
>
{{ t('extension.nudge.title') }}
</div>
<div class="text-xs text-purple-600 dark:text-purple-300">
{{ t('extension.nudge.description') }}
</div>
</div>
</div>

<div class="flex items-center gap-2">
<Button
color="primary"
size="md"
iconName="IconPlay"
:label="t('extension.nudge.cta')"
@click="install"
class="border-none bg-gradient-to-r from-pink-500 to-purple-600 shadow-md transition-all duration-300 hover:from-pink-600 hover:to-purple-700 hover:shadow-lg"
/>
<button
type="button"
:aria-label="t('extension.nudge.dismiss')"
@click="dismiss"
class="flex h-8 w-8 items-center justify-center rounded-full text-purple-700 transition-colors hover:bg-purple-200/60 dark:text-purple-200 dark:hover:bg-purple-800/60"
>
<Icon name="IconX" class="h-4 w-4" />
</button>
</div>
</div>
</Card>
</section>
</template>

<script setup lang="ts">
import { Button, Card, Icon } from 'pilotui/elements';

const { t } = useI18n();
const config = useRuntimeConfig();
const { extensionPresent } = useExtensionPresence();

const STORAGE_KEY = 'extensionNudgeDismissed';
const dismissed = ref(false);

onMounted(() => {
dismissed.value = sessionStorage.getItem(STORAGE_KEY) === '1';
});

const isVisible = computed(() => extensionPresent.value === false && !dismissed.value);

function dismiss() {
sessionStorage.setItem(STORAGE_KEY, '1');
dismissed.value = true;
}

function install() {
const url = config.public.chromeWebStoreUrl as string;
window.open(url, '_blank', 'noopener,noreferrer');
}
</script>
9 changes: 8 additions & 1 deletion frontend/components/partial/ProfileButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<li>
<div class="flex items-center px-4 py-4">
<div class="flex-none">
<img class="h-10 w-10 rounded-md object-cover" :src="profilePicture" />
<img class="h-10 w-10 rounded-md object-cover" :src="profilePicture" @error="onAvatarLoadError" />
</div>
<div class="truncate ltr:pl-4 rtl:pr-4">
<h4 class="text-base">
Expand Down Expand Up @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions frontend/composables/useExtensionPresence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const PROBE_TIMEOUT_MS = 500;

// Module-scoped so every caller shares one probe per app session.
const extensionPresent = ref<boolean | null>(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 };
}
10 changes: 9 additions & 1 deletion frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
1 change: 1 addition & 0 deletions frontend/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},

Expand Down
11 changes: 9 additions & 2 deletions frontend/pages/settings/profile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<!-- Avatar Section -->
<div class="mb-6">
<div class="group relative mx-auto h-24 w-24 md:h-32 md:w-32">
<img :src="profilePhotoPreview" alt="Profile Photo"
<img :src="profilePhotoPreview" alt="Profile Photo" @error="onAvatarLoadError"
class="h-24 w-24 cursor-pointer rounded-full border border-gray-200 object-cover transition-opacity group-hover:opacity-80 md:h-32 md:w-32" />
<div
class="absolute inset-0 flex items-center justify-center rounded-full bg-black bg-opacity-0 transition-all duration-200 group-hover:bg-opacity-30">
Expand Down Expand Up @@ -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<File | null>(null);
const filePreviewUrl = ref<string | null>(null);
const options = [{ label: t('profile.receive-daily-practice-email-reminders'), value: 'dailyReminders' }];
Expand Down Expand Up @@ -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;
Expand Down
115 changes: 57 additions & 58 deletions frontend/pages/statistic.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
<template>
<div class="relative min-h-screen">
<!-- Decorative Background Elements -->
<div
class="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/5 rounded-full blur-[120px] pointer-events-none">
</div>
<div
class="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-secondary/5 rounded-full blur-[120px] pointer-events-none">
</div>
<div class="pointer-events-none absolute left-[-10%] top-[-10%] h-[40%] w-[40%] rounded-full bg-primary/5 blur-[120px]"></div>
<div class="pointer-events-none absolute bottom-[-10%] right-[-10%] h-[40%] w-[40%] rounded-full bg-secondary/5 blur-[120px]"></div>

<div class="container relative mx-auto max-w-7xl px-6 py-16">
<InstallExtensionBanner class="mb-6" />

<div class="container relative mx-auto px-6 py-16 max-w-7xl">
<PageHeader :title="t('statistic.your-statistic')" overline="ANALYTICS" />

<section class="mb-6 grid grid-cols-1 gap-6 lg:grid-cols-4">
Expand Down Expand Up @@ -52,62 +50,63 @@
</template>

<script setup lang="ts">
import { dataProvider, functionProvider } from '@modular-rest/client';
import { COLLECTIONS, DATABASE, type PhraseBundleType } from '~/types/database.type';
import { FN, type UserStatisticType } from '~/types/function.type';
import { Card, IconButton } from 'pilotui/elements';
import PageHeader from '~/components/common/PageHeader.vue';
import { dataProvider, functionProvider } from '@modular-rest/client';
import { COLLECTIONS, DATABASE, type PhraseBundleType } from '~/types/database.type';
import { FN, type UserStatisticType } from '~/types/function.type';
import { Card, IconButton } from 'pilotui/elements';
import PageHeader from '~/components/common/PageHeader.vue';
import InstallExtensionBanner from '~/components/extension_nudge/InstallExtensionBanner.vue';

const { t } = useI18n();
const { t } = useI18n();

definePageMeta({
layout: 'default',
title: () => t('statistic.your-statistic'),
// @ts-ignore
middleware: ['auth'],
});
definePageMeta({
layout: 'default',
title: () => t('statistic.your-statistic'),
// @ts-ignore
middleware: ['auth'],
});

const recentBundles = ref<PhraseBundleType[]>([]);
const statistics = ref<UserStatisticType>({
totalPhrases: 0,
totalBundles: 0,
});
const recentBundles = ref<PhraseBundleType[]>([]);
const statistics = ref<UserStatisticType>({
totalPhrases: 0,
totalBundles: 0,
});

function getRecentBundles() {
dataProvider
.find<PhraseBundleType>({
database: DATABASE.USER_CONTENT,
collection: COLLECTIONS.PHRASE_BUNDLE,
query: {
refId: authUser.value?.id,
},
options: {
limit: 3,
sort: {
updatedAt: -1,
function getRecentBundles() {
dataProvider
.find<PhraseBundleType>({
database: DATABASE.USER_CONTENT,
collection: COLLECTIONS.PHRASE_BUNDLE,
query: {
refId: authUser.value?.id,
},
},
})
.then((data) => {
recentBundles.value = data;
});
}
options: {
limit: 3,
sort: {
updatedAt: -1,
},
},
})
.then((data) => {
recentBundles.value = data;
});
}

function getUserStatistics() {
functionProvider
.run<UserStatisticType>({
name: FN.getUserStatistic,
args: {
userId: authUser.value?.id,
},
})
.then((data) => {
statistics.value = data;
});
}
function getUserStatistics() {
functionProvider
.run<UserStatisticType>({
name: FN.getUserStatistic,
args: {
userId: authUser.value?.id,
},
})
.then((data) => {
statistics.value = data;
});
}

onMounted(() => {
getRecentBundles();
getUserStatistics();
});
onMounted(() => {
getRecentBundles();
getUserStatistics();
});
</script>
Loading
Loading