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
118 changes: 61 additions & 57 deletions frontend/components/bundle/GenerativeCard.vue
Original file line number Diff line number Diff line change
@@ -1,70 +1,74 @@
<template>
<NuxtLink :to="`/bundles/${props.bundle._id}`">
<Card class="no-padding min-h-full rounded-md shadow-none hover:shadow-lg">
<section class="relative">
<IconButton class="absolute right-2 top-2 z-10 min-w-10 justify-center p-2" rounded="md">
{{ bundle.phrases.length }}
</IconButton>
<MaterialWordGenerativeCover :words="phraseList" :classes="[' w-full']" @click="navigateToBundle" />
</section>
<section class="p-6">
<span class="mb-2 text-sm font-bold text-gray-800 dark:text-white">
{{ props.bundle.title }}
</span>

<p class="text-sm text-gray-400">
{{ props.bundle.desc }}
</p>
</section>
</Card>
<NuxtLink :to="`/bundles/${props.bundle._id}`" class="block">
<StBundleCard
:title="props.bundle.title"
:count="props.bundle.phrases.length"
:source-lang="langPair.source"
:target-lang="langPair.target"
:cover="cover"
:practicable="false"
>
<!-- The generated word cloud stands in for the design's flat gradient cover. -->
<template #cover>
<MaterialWordGenerativeCover :words="phraseList" :classes="['w-full', 'h-full']" />
</template>
</StBundleCard>
</NuxtLink>
</template>

<script setup lang="ts">
import { dataProvider } from '@modular-rest/client';
import { COLLECTIONS, DATABASE, type PhraseBundleType, type PhraseType } from '~/types/database.type';
import { Card, IconButton } from 'pilotui/elements';
import { dataProvider } from '@modular-rest/client';
import { COLLECTIONS, DATABASE, type PhraseBundleType, type PhraseType } from '~/types/database.type';
import { StBundleCard } from 'subturtle-ui';
import { toLanguageCode } from '~/utils/language';

const props = defineProps<{
bundle: PhraseBundleType;
}>();
const props = defineProps<{
bundle: PhraseBundleType;
}>();

const phraseList = ref<string[]>([]);
const phraseList = ref<string[]>([]);
/** Bundles carry no language of their own, so it is read off the phrases inside them. */
const langPair = ref<{ source?: string; target?: string }>({});

async function getWords() {
// get last 10 words
const phraseIds = props.bundle.phrases.slice(-10);
/** Cover tints rotate by bundle id so a grid of cards isn't all one color. */
const COVERS = ['rose', 'jade', 'sky', 'amber', 'ink'] as const;
const cover = computed(() => {
const seed = [...(props.bundle._id ?? '')].reduce((sum, ch) => sum + ch.charCodeAt(0), 0);
return COVERS[seed % COVERS.length];
});

if (phraseIds.length === 0) {
return;
}
async function getWords() {
// get last 10 words
const phraseIds = props.bundle.phrases.slice(-10);

const phrases = await dataProvider.findByIds<PhraseType>({
database: DATABASE.USER_CONTENT,
collection: COLLECTIONS.PHRASE,
ids: phraseIds,
accessQuery: {
refId: authUser.value?.id,
},
});
if (phraseIds.length === 0) {
return;
}

phraseList.value = phrases.map(({ phrase, translation }) => {
// choose between phrase and translation
return [phrase, translation][Math.floor(Math.random() * 2)];
});
}
const phrases = await dataProvider.findByIds<PhraseType>({
database: DATABASE.USER_CONTENT,
collection: COLLECTIONS.PHRASE,
ids: phraseIds,
accessQuery: {
refId: authUser.value?.id,
},
});

onMounted(async () => {
getWords();
});
phraseList.value = phrases.map(({ phrase, translation }) => {
// choose between phrase and translation
return [phrase, translation][Math.floor(Math.random() * 2)];
});

function navigateToBundle() {
// navigate to the bundle page
navigateTo(`/bundles/${props.bundle._id}`);
}
</script>
const withLanguages = phrases.find((p) => p.language_info?.source || p.translation_language);
if (withLanguages) {
langPair.value = {
source: toLanguageCode(withLanguages.language_info?.source),
target: toLanguageCode(withLanguages.language_info?.target ?? withLanguages.translation_language),
};
}
}

<style lang="css" scoped>
.no-padding {
padding: 0 !important;
}
</style>
onMounted(async () => {
getWords();
});
</script>
30 changes: 30 additions & 0 deletions frontend/components/common/StPageHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<template>
<div class="flex flex-wrap items-end justify-between gap-6">
<div>
<div v-if="overline" class="st-overline mb-[10px]">{{ overline }}</div>
<h1 class="font-st-display text-st-2xl font-black leading-[1.05] tracking-st-tight text-st-strong">
{{ title }}
</h1>
<p v-if="subtitle" class="mt-2 max-w-[32rem] text-st-md font-semibold leading-normal text-st-muted [text-wrap:pretty]">
{{ subtitle }}
</p>
</div>

<div v-if="$slots.actions" class="flex items-center gap-3 pb-1">
<slot name="actions" />
</div>
</div>
</template>

<script setup lang="ts">
/**
* Page header in the redesigned language: rose eyebrow, black display title, muted lead.
* The older `PageHeader.vue` stays until the last pilotui-era screen is migrated.
* `.st-overline` is a helper class from subturtle-ui/style.css.
*/
defineProps<{
title: string;
subtitle?: string;
overline?: string;
}>();
</script>
32 changes: 30 additions & 2 deletions frontend/components/material/WordGenerativeCover.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="relative">
<iframe ref="container" :class="[...props.classes, 'bg-blue-400']" :src="getUrl()" />
<div ref="wrapper" class="relative h-full w-full">
<iframe ref="container" :key="renderKey" :class="[...props.classes, 'bg-transparent']" :src="getUrl()" />
<!-- Transparent overlay to handle clicks properly -->
<div class="absolute inset-0 z-10 cursor-pointer" @click="emitClick" />
</div>
Expand All @@ -14,6 +14,34 @@

const emit = defineEmits(['click']);

const wrapper = ref<HTMLElement | null>(null);
// The word cloud sizes its canvas from window.innerWidth/Height once, at load. Bumping this
// key remounts the iframe so it re-measures; without it a cover laid out after load (grid
// reflow, sidebar collapse) paints a canvas narrower than its frame and leaves a bare strip.
const renderKey = ref(0);
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
let observer: ResizeObserver | undefined;
let lastWidth = 0;

onMounted(() => {
if (!wrapper.value || typeof ResizeObserver === 'undefined') return;

observer = new ResizeObserver(([entry]) => {
const width = Math.round(entry.contentRect.width);
if (width === 0 || width === lastWidth) return;
lastWidth = width;

clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => renderKey.value++, 150);
});
observer.observe(wrapper.value);
});

onBeforeUnmount(() => {
clearTimeout(resizeTimer);
observer?.disconnect();
});

function getUrl() {
return '/wordcloud/index.html?words=' + encodeURIComponent(JSON.stringify(props.words));
}
Expand Down
41 changes: 41 additions & 0 deletions frontend/components/widget/WeekActivityChart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<div>
<div class="flex h-[260px] items-end gap-[10px]">
<div v-for="day in bars" :key="day.date" class="flex h-full flex-1 flex-col items-center justify-end gap-[10px]">
<span class="text-sm font-extrabold" :class="day.peak ? 'text-st-strong' : 'text-st-faint'">
{{ day.value }}
</span>
<div class="w-full max-w-[56px] rounded-st-md" :style="{ height: day.height, background: day.fill, boxShadow: day.shadow }" />
</div>
</div>
<div class="mt-[14px] flex gap-[10px]">
<span v-for="day in bars" :key="day.date" class="flex-1 text-center text-sm font-bold text-st-faint">
{{ day.label }}
</span>
</div>
</div>
</template>

<script setup lang="ts">
import type { ProgressDay } from '~/composables/useProgressSummary';

const props = defineProps<{ days: ProgressDay[] }>();

/**
* The design highlights the best day of the week in the brand gradient and leaves the rest
* flat. Bars are sized against peak * 1.12 so the tallest one never touches the ceiling.
*/
const bars = computed(() => {
const peak = Math.max(0, ...props.days.map((d) => d.value));
return props.days.map((d) => {
const isPeak = peak > 0 && d.value === peak;
return {
...d,
peak: isPeak,
height: peak > 0 ? `${((d.value / (peak * 1.12)) * 100).toFixed(1)}%` : '0%',
fill: isPeak ? 'linear-gradient(180deg, rgb(var(--rose-400)) 0%, rgb(var(--rose-600)) 100%)' : 'rgb(var(--ink-100))',
shadow: isPeak ? 'var(--shadow-primary)' : 'none',
};
});
});
</script>
128 changes: 64 additions & 64 deletions frontend/composables/useDashboardNavigatorItems.ts
Original file line number Diff line number Diff line change
@@ -1,68 +1,68 @@
import type { SidebarGroupType } from 'pilotui/types';
import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue';
import type { StNavGroup } from 'subturtle-ui';

export const useDashboardNavigatorItems = (): Array<SidebarGroupType> => {
/**
* Route for each nav id. StAppShell is id-based (it emits `navigate(id)`), so the mapping
* from id to route lives here rather than on the items themselves.
*/
export const DASHBOARD_NAV_ROUTES: Record<string, string> = {
stats: '/statistic',
board: '/board',
bundles: '/bundles',
prefs: '/settings/preferences',
newsession: '/sessions/new',
sessions: '/sessions',
plans: '/settings/subscription',
};

/** Longest-prefix match, so /bundles/:id still highlights "Phrase bundles". */
export const activeNavId = (path: string): string | undefined =>
Object.entries(DASHBOARD_NAV_ROUTES)
.filter(([, to]) => path === to || path.startsWith(`${to}/`))
.sort((a, b) => b[1].length - a[1].length)[0]?.[0];

export interface DashboardNavBadges {
/** Phrases due for review today — the nudge the design puts on "Today's board". */
board?: number | null;
}

export const useDashboardNavigatorItems = (badges?: MaybeRefOrGetter<DashboardNavBadges>): ComputedRef<Array<StNavGroup>> => {
// useI18n has to run during setup, so the groups are rebuilt inside a computed rather
// than the composable being re-invoked whenever a badge changes.
const { t } = useI18n();

return [
{
title: t('dashboard'),
children: [
{
title: t('statistic.your-statistic'),
icon: 'IconMenuDashboard',
to: '/statistic',
},
],
},
{
title: t('practice.title'),
children: [
{
title: t('activities.title'),
icon: 'iconify solar--rocket-2-bold-duotone',
to: '/board',
},
{
title: t('bundle.nav'),
icon: 'IconMenuDatatables',
to: '/bundles',
},
{
title: t('preferences.nav'),
icon: 'IconSettings',
to: '/settings/preferences',
},
],
},
{
title: t('ai-coaching'),
children: [
{
title: t('live-session.start-new-session'),
icon: 'IconMenuDatatables',
to: '/sessions/new',
},
{
title: t('live-session.session-history'),
icon: 'iconify solar--history-2-bold',
to: '/sessions',
},
],
},
{
title: t('settings'),
children: [
// {
// title: t('profile.profile'),
// icon: 'IconUser',
// to: '/settings/profile',
// },
{
title: t('subscription.title'),
icon: 'IconCreditCard',
to: '/settings/subscription',
},
],
},
] as Array<SidebarGroupType>;
return computed(() => {
const badge = toValue(badges) ?? {};

return [
{
section: t('nav.overview'),
items: [{ id: 'stats', label: t('nav.your-progress'), icon: 'solar:chart-2-bold-duotone' }],
},
{
section: t('practice.title'),
items: [
{
id: 'board',
label: t('nav.todays-board'),
icon: 'solar:rocket-2-bold-duotone',
badge: badge.board || null,
},
{ id: 'bundles', label: t('bundle.nav'), icon: 'solar:notebook-bold-duotone' },
{ id: 'prefs', label: t('preferences.nav'), icon: 'solar:settings-bold-duotone' },
],
},
{
section: t('ai-coaching'),
items: [
{ id: 'newsession', label: t('nav.start-a-session'), icon: 'solar:microphone-3-bold-duotone' },
{ id: 'sessions', label: t('live-session.session-history'), icon: 'solar:history-2-bold-duotone' },
],
},
{
section: t('nav.account'),
items: [{ id: 'plans', label: t('subscription.title'), icon: 'solar:crown-bold-duotone' }],
},
];
});
};
Loading
Loading