From d17c8a341dd5873250000b50221e7e3a633f657c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 10 Apr 2026 13:08:18 +0200 Subject: [PATCH] feat(vocab): add language and course dictionary endpoints and UI components - 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. --- backend/controllers/vocabController.js | 6 + backend/routers/vocabRouter.js | 2 + backend/services/vocabService.js | 58 ++++ frontend/src/components/AppSectionBar.vue | 10 + frontend/src/i18n/locales/ceb/general.json | 1 + .../src/i18n/locales/ceb/socialnetwork.json | 13 + frontend/src/i18n/locales/de/general.json | 1 + .../src/i18n/locales/de/socialnetwork.json | 13 + frontend/src/i18n/locales/en/general.json | 1 + .../src/i18n/locales/en/socialnetwork.json | 13 + frontend/src/i18n/locales/es/general.json | 1 + .../src/i18n/locales/es/socialnetwork.json | 13 + frontend/src/i18n/locales/fr/general.json | 1 + .../src/i18n/locales/fr/socialnetwork.json | 13 + frontend/src/router/socialRoutes.js | 45 ++-- frontend/src/views/social/VocabCourseView.vue | 11 + .../src/views/social/VocabDictionaryView.vue | 253 ++++++++++++++++++ .../src/views/social/VocabLanguageView.vue | 4 + 18 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 frontend/src/views/social/VocabDictionaryView.vue diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index bf334ad..b02b27a 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -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) ); diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index 00997f3..3159cb8 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -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); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 2f0385f..48c1d0e 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -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); diff --git a/frontend/src/components/AppSectionBar.vue b/frontend/src/components/AppSectionBar.vue index 89e116f..b791fac 100644 --- a/frontend/src/components/AppSectionBar.vue +++ b/frontend/src/components/AppSectionBar.vue @@ -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}`; } diff --git a/frontend/src/i18n/locales/ceb/general.json b/frontend/src/i18n/locales/ceb/general.json index ea687f0..7100607 100644 --- a/frontend/src/i18n/locales/ceb/general.json +++ b/frontend/src/i18n/locales/ceb/general.json @@ -148,6 +148,7 @@ "subscribeLanguage": "Mag-subscribe sa pinulongan", "language": "Pinulongan", "chapter": "Kapitulo", + "vocabDictionary": "Dictionary", "courses": "Mga kurso", "course": "Kurso", "lesson": "Leksiyon", diff --git a/frontend/src/i18n/locales/ceb/socialnetwork.json b/frontend/src/i18n/locales/ceb/socialnetwork.json index d677351..3a34953 100644 --- a/frontend/src/i18n/locales/ceb/socialnetwork.json +++ b/frontend/src/i18n/locales/ceb/socialnetwork.json @@ -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)" } } } diff --git a/frontend/src/i18n/locales/de/general.json b/frontend/src/i18n/locales/de/general.json index 382c884..ceb4897 100644 --- a/frontend/src/i18n/locales/de/general.json +++ b/frontend/src/i18n/locales/de/general.json @@ -148,6 +148,7 @@ "subscribeLanguage": "Sprache abonnieren", "language": "Sprache", "chapter": "Kapitel", + "vocabDictionary": "Wörterbuch", "courses": "Kurse", "course": "Kurs", "lesson": "Lektion", diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 4157489..116caab 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -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", diff --git a/frontend/src/i18n/locales/en/general.json b/frontend/src/i18n/locales/en/general.json index 25991fb..2cebe3b 100644 --- a/frontend/src/i18n/locales/en/general.json +++ b/frontend/src/i18n/locales/en/general.json @@ -148,6 +148,7 @@ "subscribeLanguage": "Subscribe to language", "language": "Language", "chapter": "Chapter", + "vocabDictionary": "Dictionary", "courses": "Courses", "course": "Course", "lesson": "Lesson", diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index 8342b41..53a20c2 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -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", diff --git a/frontend/src/i18n/locales/es/general.json b/frontend/src/i18n/locales/es/general.json index 6b6734d..526e46a 100644 --- a/frontend/src/i18n/locales/es/general.json +++ b/frontend/src/i18n/locales/es/general.json @@ -148,6 +148,7 @@ "subscribeLanguage": "Suscribirse al idioma", "language": "Idioma", "chapter": "Capítulo", + "vocabDictionary": "Diccionario", "courses": "Cursos", "course": "Curso", "lesson": "Lección", diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index e21efb6..6d1a9a1 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -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", diff --git a/frontend/src/i18n/locales/fr/general.json b/frontend/src/i18n/locales/fr/general.json index beb6557..64254d2 100644 --- a/frontend/src/i18n/locales/fr/general.json +++ b/frontend/src/i18n/locales/fr/general.json @@ -148,6 +148,7 @@ "subscribeLanguage": "Abonnez-vous à la langue", "language": "Langue", "chapter": "Chapitre", + "vocabDictionary": "Dictionnaire", "courses": "Cours", "course": "Kurs", "lesson": "Lektion", diff --git a/frontend/src/i18n/locales/fr/socialnetwork.json b/frontend/src/i18n/locales/fr/socialnetwork.json index bcdde32..2a533e7 100644 --- a/frontend/src/i18n/locales/fr/socialnetwork.json +++ b/frontend/src/i18n/locales/fr/socialnetwork.json @@ -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 l’une ou l’autre langue…", + "empty": "Aucune entrée.", + "loadError": "Impossible de charger le dictionnaire.", + "notFound": "Pas d’accè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", diff --git a/frontend/src/router/socialRoutes.js b/frontend/src/router/socialRoutes.js index b6019b9..b4dad60 100644 --- a/frontend/src/router/socialRoutes.js +++ b/frontend/src/router/socialRoutes.js @@ -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; diff --git a/frontend/src/views/social/VocabCourseView.vue b/frontend/src/views/social/VocabCourseView.vue index 6f4b7a7..781bd0e 100644 --- a/frontend/src/views/social/VocabCourseView.vue +++ b/frontend/src/views/social/VocabCourseView.vue @@ -16,6 +16,9 @@ {{ $t('socialnetwork.vocab.courses.shareCode') }}: {{ course.shareCode }} +
@@ -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 { diff --git a/frontend/src/views/social/VocabDictionaryView.vue b/frontend/src/views/social/VocabDictionaryView.vue new file mode 100644 index 0000000..aa79afa --- /dev/null +++ b/frontend/src/views/social/VocabDictionaryView.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/frontend/src/views/social/VocabLanguageView.vue b/frontend/src/views/social/VocabLanguageView.vue index 459555d..a20d064 100644 --- a/frontend/src/views/social/VocabLanguageView.vue +++ b/frontend/src/views/social/VocabLanguageView.vue @@ -24,6 +24,7 @@
+
@@ -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}`); },