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
17 changes: 15 additions & 2 deletions .github/workflows/auto-pr-description.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,22 @@

name: Generate PR Description

# DISABLED. This ran on every `opened` and `synchronize` and overwrote the pull request body
# wholesale, discarding hand-written descriptions on each push. It also fabricated ClickUp
# links from whatever it inferred, producing dead task references.
#
# The file is kept rather than deleted so the setup is recoverable. `workflow_dispatch` is only
# here to keep the file valid — the steps below read `github.event.pull_request`, so a manual
# run would need the PR number wired in as an input first.
#
# To turn automation back on: restore the `pull_request` trigger below AND re-enable the
# workflow in the repo (`gh workflow enable auto-pr-description.yml`). Both are required — a
# `pull_request` workflow runs from the BASE branch's copy, so editing this file alone changes
# nothing for PRs already targeting an existing branch.
on:
pull_request:
types: [opened, synchronize]
workflow_dispatch:
# pull_request:
# types: [opened, synchronize]

env:
TEMPLATE_FILE_PATH: .github/pr-description-template.md
Expand Down
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ FROM node:22.17.1-alpine as build-stage

WORKDIR /app

# subturtle-ui is a link:../ui dependency whose dist/ is not committed, so the library's
# source has to be in the image and gets built by frontend's postinstall. The path must stay
# ../ui relative to this WORKDIR.
COPY /ui /ui

COPY /frontend/package.json ./
COPY /frontend/yarn.lock ./
COPY /frontend/.npmrc ./
Expand Down
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>
Loading
Loading