feat(vocab): add language and course dictionary endpoints and UI components
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s

- Implemented `getLanguageDictionary` and `getCourseDictionary` methods in the VocabService to retrieve vocabulary entries filtered by search terms.
- Updated VocabController and vocabRouter to include new routes for accessing language and course dictionaries.
- Enhanced frontend components to navigate to the new dictionary views, including buttons in VocabCourseView and VocabLanguageView.
- Added localization entries for the dictionary feature in multiple languages, ensuring a consistent user experience across the platform.
This commit is contained in:
Torsten Schulz (local)
2026-04-10 13:08:18 +02:00
parent 9582e7b900
commit d17c8a341d
18 changed files with 443 additions and 16 deletions

View File

@@ -18,6 +18,9 @@ class VocabController {
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
this.getLanguageDictionary = this._wrapWithUser((userId, req) =>
this.service.getLanguageDictionary(userId, req.params.languageId, req.query)
);
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
@@ -31,6 +34,9 @@ class VocabController {
this.getCompletedLessonVocabPool = this._wrapWithUser((userId, req) =>
this.service.getCompletedLessonVocabPool(userId, req.params.courseId, req.query.untilLessonId)
);
this.getCourseDictionary = this._wrapWithUser((userId, req) =>
this.service.getCourseDictionary(userId, req.params.courseId, req.query)
);
this.getVocabDistractorPool = this._wrapWithUser((userId, req) =>
this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId)
);

View File

@@ -20,6 +20,7 @@ router.get('/languages/:languageId/chapters', vocabController.listChapters);
router.post('/languages/:languageId/chapters', vocabController.createChapter);
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
router.get('/languages/:languageId/search', vocabController.searchVocabs);
router.get('/languages/:languageId/dictionary', vocabController.getLanguageDictionary);
router.get('/chapters/:chapterId', vocabController.getChapter);
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
@@ -32,6 +33,7 @@ router.get('/courses', vocabController.getCourses);
router.get('/courses/my', vocabController.getMyCourses);
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getCompletedLessonVocabPool);
router.get('/courses/:courseId/dictionary', vocabController.getCourseDictionary);
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
router.get('/courses/:courseId', vocabController.getCourse);
router.put('/courses/:courseId', vocabController.updateCourse);

View File

@@ -1605,6 +1605,64 @@ export default class VocabService {
return { languageId: access.id, results: rows };
}
/**
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
* Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE).
*/
async getLanguageDictionary(hashedUserId, languageId, { q } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const term = typeof q === 'string' ? q.trim() : '';
const like = term ? `%${term}%` : null;
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "reference"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
LIMIT 20000
`,
{
replacements: like ? { languageId: access.id, like } : { languageId: access.id },
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, results: rows };
}
/**
* Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten).
*/
async getCourseDictionary(hashedUserId, courseId, { q } = {}) {
const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId);
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
let vocabs = pool.vocabs || [];
if (term) {
vocabs = vocabs.filter((entry) => {
const l = String(entry.learning || '').toLowerCase();
const r = String(entry.reference || '').toLowerCase();
return l.includes(term) || r.includes(term);
});
}
vocabs.sort((a, b) => {
const refCmp = String(a.reference || '').localeCompare(String(b.reference || ''), undefined, { sensitivity: 'base' });
if (refCmp !== 0) return refCmp;
return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' });
});
return { courseId: pool.courseId, results: vocabs };
}
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);

View File

@@ -41,8 +41,10 @@ const TITLE_MAP = {
VocabNewLanguage: 'sectionBar.titles.newLanguage',
VocabSubscribe: 'sectionBar.titles.subscribeLanguage',
VocabLanguage: 'sectionBar.titles.language',
VocabLanguageDictionary: 'sectionBar.titles.vocabDictionary',
VocabChapter: 'sectionBar.titles.chapter',
VocabCourses: 'sectionBar.titles.courses',
VocabCourseDictionary: 'sectionBar.titles.vocabDictionary',
VocabCourse: 'sectionBar.titles.course',
VocabLesson: 'sectionBar.titles.lesson',
FalukantCreate: 'sectionBar.titles.createCharacter',
@@ -102,6 +104,14 @@ export default {
backTarget() {
const params = this.$route?.params || {};
if (this.routePath.endsWith('/dictionary') && params.courseId) {
return `/socialnetwork/vocab/courses/${params.courseId}`;
}
if (this.routePath.endsWith('/dictionary') && params.languageId) {
return `/socialnetwork/vocab/${params.languageId}`;
}
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
return `/socialnetwork/vocab/courses/${params.courseId}`;
}

View File

@@ -148,6 +148,7 @@
"subscribeLanguage": "Mag-subscribe sa pinulongan",
"language": "Pinulongan",
"chapter": "Kapitulo",
"vocabDictionary": "Dictionary",
"courses": "Mga kurso",
"course": "Kurso",
"lesson": "Leksiyon",

View File

@@ -785,6 +785,19 @@
"search": "Pangita",
"noResults": "Walay results.",
"error": "Pangita failed."
},
"dictionary": {
"open": "Dictionary",
"kicker": "Tan-awa",
"intro": "Tanang termino gikan sa imong mga kapitulo. Usa ka field para sa partial match sa duha ka pinulongan.",
"searchLabel": "Pangita (duha ka pinulongan)",
"searchPlaceholder": "Part sa pulong sa bisan unsang pinulongan …",
"empty": "Walay entry.",
"loadError": "Dili ma-load ang dictionary.",
"notFound": "Walay access o wala makita.",
"languageTitle": "Dictionary: {name}",
"courseTitle": "Dictionary sa kurso: {name}",
"courseLearningColumn": "Pagkat-on nga pinulongan (kurso)"
}
}
}

View File

@@ -148,6 +148,7 @@
"subscribeLanguage": "Sprache abonnieren",
"language": "Sprache",
"chapter": "Kapitel",
"vocabDictionary": "Wörterbuch",
"courses": "Kurse",
"course": "Kurs",
"lesson": "Lektion",

View File

@@ -468,6 +468,19 @@
"noResults": "Keine Treffer.",
"error": "Suche fehlgeschlagen."
},
"dictionary": {
"open": "Wörterbuch",
"kicker": "Nachschlagen",
"intro": "Alle Begriffe aus deinen Kapiteln. Ein Suchfeld filtert in beiden Sprachen nach Teilwörtern.",
"searchLabel": "Suche (beide Sprachen)",
"searchPlaceholder": "Teilwort in Lern- oder Muttersprache …",
"empty": "Keine Einträge.",
"loadError": "Wörterbuch konnte nicht geladen werden.",
"notFound": "Kein Zugriff oder nicht gefunden.",
"languageTitle": "Wörterbuch: {name}",
"courseTitle": "Kurs-Wörterbuch: {name}",
"courseLearningColumn": "Lernsprache (Kurs)"
},
"courses": {
"title": "Sprachlernkurse",
"create": "Kurs erstellen",

View File

@@ -148,6 +148,7 @@
"subscribeLanguage": "Subscribe to language",
"language": "Language",
"chapter": "Chapter",
"vocabDictionary": "Dictionary",
"courses": "Courses",
"course": "Course",
"lesson": "Lesson",

View File

@@ -468,6 +468,19 @@
"noResults": "No results.",
"error": "Search failed."
},
"dictionary": {
"open": "Dictionary",
"kicker": "Lookup",
"intro": "All terms from your chapters. One field filters both languages by partial matches.",
"searchLabel": "Search (both languages)",
"searchPlaceholder": "Part of a word in either language…",
"empty": "No entries.",
"loadError": "Could not load the dictionary.",
"notFound": "No access or not found.",
"languageTitle": "Dictionary: {name}",
"courseTitle": "Course dictionary: {name}",
"courseLearningColumn": "Learning language (course)"
},
"courses": {
"title": "Language Learning Courses",
"create": "Create Course",

View File

@@ -148,6 +148,7 @@
"subscribeLanguage": "Suscribirse al idioma",
"language": "Idioma",
"chapter": "Capítulo",
"vocabDictionary": "Diccionario",
"courses": "Cursos",
"course": "Curso",
"lesson": "Lección",

View File

@@ -466,6 +466,19 @@
"noResults": "Sin resultados.",
"error": "La búsqueda ha fallado."
},
"dictionary": {
"open": "Diccionario",
"kicker": "Consulta",
"intro": "Todos los términos de tus capítulos. Un solo campo filtra en ambos idiomas por coincidencias parciales.",
"searchLabel": "Búsqueda (ambos idiomas)",
"searchPlaceholder": "Parte de una palabra en cualquier idioma…",
"empty": "Sin entradas.",
"loadError": "No se pudo cargar el diccionario.",
"notFound": "Sin acceso o no encontrado.",
"languageTitle": "Diccionario: {name}",
"courseTitle": "Diccionario del curso: {name}",
"courseLearningColumn": "Idioma de aprendizaje (curso)"
},
"courses": {
"title": "Cursos de idiomas",
"create": "Crear curso",

View File

@@ -148,6 +148,7 @@
"subscribeLanguage": "Abonnez-vous à la langue",
"language": "Langue",
"chapter": "Chapitre",
"vocabDictionary": "Dictionnaire",
"courses": "Cours",
"course": "Kurs",
"lesson": "Lektion",

View File

@@ -466,6 +466,19 @@
"noResults": "Aucun coup sûr.",
"error": "La recherche a échoué."
},
"dictionary": {
"open": "Dictionnaire",
"kicker": "Consulter",
"intro": "Tous les termes de vos chapitres. Un seul champ filtre les deux langues par correspondances partielles.",
"searchLabel": "Recherche (les deux langues)",
"searchPlaceholder": "Fragment dans lune ou lautre langue…",
"empty": "Aucune entrée.",
"loadError": "Impossible de charger le dictionnaire.",
"notFound": "Pas daccès ou introuvable.",
"languageTitle": "Dictionnaire : {name}",
"courseTitle": "Dictionnaire du cours : {name}",
"courseLearningColumn": "Langue apprise (cours)"
},
"courses": {
"title": "Cours d'apprentissage des langues",
"create": "Créer un cours",

View File

@@ -14,6 +14,7 @@ const VocabCourseListView = () => import('../views/social/VocabCourseListView.vu
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
const VocabLessonReviewView = () => import('../views/social/VocabLessonReviewView.vue');
const VocabDictionaryView = () => import('../views/social/VocabDictionaryView.vue');
const EroticAccessView = () => import('../views/social/EroticAccessView.vue');
const EroticPicturesView = () => import('../views/social/EroticPicturesView.vue');
const EroticVideosView = () => import('../views/social/EroticVideosView.vue');
@@ -97,18 +98,6 @@ const socialRoutes = [
component: VocabSubscribeView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId',
name: 'VocabLanguage',
component: VocabLanguageView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
name: 'VocabChapter',
component: VocabChapterView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/courses',
name: 'VocabCourses',
@@ -116,10 +105,9 @@ const socialRoutes = [
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/courses/:courseId',
name: 'VocabCourse',
component: VocabCourseView,
props: true,
path: '/socialnetwork/vocab/courses/:courseId/dictionary',
name: 'VocabCourseDictionary',
component: VocabDictionaryView,
meta: { requiresAuth: true }
},
{
@@ -136,6 +124,31 @@ const socialRoutes = [
props: true,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/courses/:courseId',
name: 'VocabCourse',
component: VocabCourseView,
props: true,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId/dictionary',
name: 'VocabLanguageDictionary',
component: VocabDictionaryView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
name: 'VocabChapter',
component: VocabChapterView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId',
name: 'VocabLanguage',
component: VocabLanguageView,
meta: { requiresAuth: true }
},
];
export default socialRoutes;

View File

@@ -16,6 +16,9 @@
<span v-if="course.shareCode && isOwner" class="share-code">
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>
</span>
<button type="button" class="course-dictionary-link" @click="goCourseDictionary">
{{ $t('socialnetwork.vocab.dictionary.open') }}
</button>
</div>
<section class="surface-card course-assistant">
@@ -691,6 +694,9 @@ export default {
showApiError(this, e, this.$t('socialnetwork.vocab.courses.deleteLessonError'));
}
},
goCourseDictionary() {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/dictionary`);
},
openLesson(lessonId) {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
},
@@ -826,6 +832,11 @@ export default {
color: #666;
flex-wrap: wrap;
padding: 16px 18px;
align-items: center;
}
.course-dictionary-link {
margin-left: auto;
}
.course-assistant {

View File

@@ -0,0 +1,253 @@
<template>
<div class="vocab-dictionary-view">
<section class="dictionary-hero surface-card">
<div v-if="metaLoading">{{ $t('general.loading') }}</div>
<div v-else-if="!contextTitle">{{ $t('socialnetwork.vocab.dictionary.notFound') }}</div>
<div v-else>
<span class="dictionary-kicker">{{ $t('socialnetwork.vocab.dictionary.kicker') }}</span>
<h2>{{ pageHeading }}</h2>
<p>{{ $t('socialnetwork.vocab.dictionary.intro') }}</p>
</div>
</section>
<div v-if="contextTitle" class="dictionary-toolbar surface-card">
<label class="dictionary-search">
<span class="dictionary-search__label">{{ $t('socialnetwork.vocab.dictionary.searchLabel') }}</span>
<input
v-model="q"
type="search"
autocomplete="off"
:placeholder="$t('socialnetwork.vocab.dictionary.searchPlaceholder')"
@input="scheduleLoad"
/>
</label>
</div>
<div class="dictionary-body surface-card">
<div v-if="listLoading" class="dictionary-state">{{ $t('general.loading') }}</div>
<div v-else-if="error" class="dictionary-state dictionary-state--error">{{ error }}</div>
<div v-else-if="results.length === 0" class="dictionary-state">{{ $t('socialnetwork.vocab.dictionary.empty') }}</div>
<table v-else class="dictionary-table">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.search.motherTongue') }}</th>
<th>{{ learningColumnLabel }}</th>
<th v-if="!isCourseMode">{{ $t('socialnetwork.vocab.chapters') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in results" :key="row.id != null ? `id-${row.id}` : `r-${idx}-${row.learning}-${row.reference}`">
<td>{{ row.reference }}</td>
<td>{{ row.learning }}</td>
<td v-if="!isCourseMode">{{ row.chapterTitle }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabDictionaryView',
data() {
return {
metaLoading: false,
listLoading: false,
contextTitle: '',
languageName: '',
q: '',
results: [],
error: '',
debounceId: null,
};
},
computed: {
isCourseMode() {
return Boolean(this.$route.params.courseId);
},
pageHeading() {
if (!this.contextTitle) return '';
if (this.isCourseMode) {
return this.$t('socialnetwork.vocab.dictionary.courseTitle', { name: this.contextTitle });
}
return this.$t('socialnetwork.vocab.dictionary.languageTitle', { name: this.contextTitle });
},
learningColumnLabel() {
if (this.isCourseMode) {
return this.$t('socialnetwork.vocab.dictionary.courseLearningColumn');
}
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
},
},
watch: {
'$route.fullPath'() {
this.bootstrap();
},
},
mounted() {
this.bootstrap();
},
beforeUnmount() {
if (this.debounceId) {
clearTimeout(this.debounceId);
}
},
methods: {
async bootstrap() {
this.q = '';
this.results = [];
this.error = '';
await this.loadContext();
if (!this.contextTitle) {
return;
}
await this.loadList();
},
scheduleLoad() {
if (this.debounceId) {
clearTimeout(this.debounceId);
}
this.debounceId = setTimeout(() => {
this.debounceId = null;
this.loadList();
}, 320);
},
async loadContext() {
this.metaLoading = true;
this.contextTitle = '';
this.languageName = '';
try {
if (this.isCourseMode) {
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}`);
this.contextTitle = data?.title || '';
} else {
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
this.contextTitle = data?.name || '';
this.languageName = data?.name || '';
}
} catch (e) {
console.error('Vocab dictionary context load failed:', e);
this.contextTitle = '';
} finally {
this.metaLoading = false;
}
},
async loadList() {
if (this.metaLoading) {
return;
}
const hasScope = this.isCourseMode
? Boolean(this.$route.params.courseId)
: Boolean(this.$route.params.languageId);
if (!hasScope) {
return;
}
this.listLoading = true;
this.error = '';
try {
const params = { q: this.q.trim() };
if (this.isCourseMode) {
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}/dictionary`, { params });
this.results = data?.results || [];
} else {
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}/dictionary`, { params });
this.results = data?.results || [];
}
} catch (e) {
console.error('Vocab dictionary list failed:', e);
this.results = [];
this.error = this.$t('socialnetwork.vocab.dictionary.loadError');
} finally {
this.listLoading = false;
}
},
},
};
</script>
<style scoped>
.vocab-dictionary-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.dictionary-hero {
padding: 24px 26px;
margin-bottom: 16px;
}
.dictionary-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dictionary-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.dictionary-toolbar {
padding: 16px 20px;
margin-bottom: 12px;
}
.dictionary-search {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 32rem;
}
.dictionary-search__label {
font-weight: 600;
font-size: 0.9rem;
}
.dictionary-search input {
width: 100%;
}
.dictionary-body {
padding: 16px 20px;
}
.dictionary-state {
color: var(--color-text-secondary);
}
.dictionary-state--error {
color: var(--color-danger, #b42318);
}
.dictionary-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
.dictionary-table th,
.dictionary-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
}
.dictionary-table th {
font-weight: 700;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
</style>

View File

@@ -24,6 +24,7 @@
<div class="row row--actions">
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
<button type="button" @click="goDictionary">{{ $t('socialnetwork.vocab.dictionary.open') }}</button>
</div>
<div class="row">
@@ -85,6 +86,9 @@ export default {
languageName: this.language?.name || '',
});
},
goDictionary() {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/dictionary`);
},
openChapter(chapterId) {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
},