diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 48c1d0e..7185ee2 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -34,6 +34,15 @@ export default class VocabService { return Math.max(min, Math.min(max, Math.trunc(numeric))); } + /** + * Wörterbuch-API: page ab 1, pageSize max. 100, Standard 25. + */ + _parseDictionaryPaging(query = {}) { + const page = Math.max(1, this._clampInteger(query?.page, { min: 1, max: 1_000_000, fallback: 1 })); + const pageSize = this._clampInteger(query?.pageSize, { min: 1, max: 100, fallback: 25 }); + return { page, pageSize }; + } + _sanitizeShortString(value, maxLength = 400) { const text = String(value ?? '').trim(); if (!text) { @@ -1609,42 +1618,77 @@ export default class VocabService { * Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert. * Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE). */ - async getLanguageDictionary(hashedUserId, languageId, { q } = {}) { + async getLanguageDictionary(hashedUserId, languageId, query = {}) { + const { q, page: pageParam, pageSize: pageSizeParam } = query; 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 { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam }); - const rows = await sequelize.query( + const baseReplacements = like ? { languageId: access.id, like } : { languageId: access.id }; + + const countRows = await sequelize.query( ` - SELECT - cl.id, - c.id AS "chapterId", - c.title AS "chapterTitle", - l1.text AS "learning", - l2.text AS "reference" + SELECT COUNT(*)::integer AS n 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 }, + replacements: baseReplacements, type: sequelize.QueryTypes.SELECT, } ); + const total = countRows[0]?.n ?? 0; + const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize); + const effectivePage = Math.min(Math.max(1, page), totalPages); + const offset = (effectivePage - 1) * pageSize; - return { languageId: access.id, results: rows }; + let rows = []; + if (total > 0) { + 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 :limit OFFSET :offset + `, + { + replacements: { ...baseReplacements, limit: pageSize, offset }, + type: sequelize.QueryTypes.SELECT, + } + ); + } + + return { + languageId: access.id, + results: rows, + total, + page: effectivePage, + pageSize, + totalPages, + }; } /** * Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten). */ - async getCourseDictionary(hashedUserId, courseId, { q } = {}) { + async getCourseDictionary(hashedUserId, courseId, query = {}) { + const { q, page: pageParam, pageSize: pageSizeParam } = query; const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId); const term = typeof q === 'string' ? q.trim().toLowerCase() : ''; let vocabs = pool.vocabs || []; @@ -1660,7 +1704,20 @@ export default class VocabService { if (refCmp !== 0) return refCmp; return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' }); }); - return { courseId: pool.courseId, results: vocabs }; + const { page, pageSize } = this._parseDictionaryPaging({ page: pageParam, pageSize: pageSizeParam }); + const total = vocabs.length; + const totalPages = total === 0 ? 1 : Math.ceil(total / pageSize); + const effectivePage = Math.min(Math.max(1, page), totalPages); + const offset = (effectivePage - 1) * pageSize; + const paged = vocabs.slice(offset, offset + pageSize); + return { + courseId: pool.courseId, + results: paged, + total, + page: effectivePage, + pageSize, + totalPages, + }; } async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) { diff --git a/frontend/src/i18n/locales/ceb/socialnetwork.json b/frontend/src/i18n/locales/ceb/socialnetwork.json index f726b50..83ef394 100644 --- a/frontend/src/i18n/locales/ceb/socialnetwork.json +++ b/frontend/src/i18n/locales/ceb/socialnetwork.json @@ -802,7 +802,10 @@ "notFound": "Walay access o wala makita.", "languageTitle": "Dictionary: {name}", "courseTitle": "Dictionary sa kurso: {name}", - "courseLearningColumn": "Pagkat-on nga pinulongan (kurso)" + "courseLearningColumn": "Pagkat-on nga pinulongan (kurso)", + "dictionaryPagerPrev": "Balik", + "dictionaryPagerNext": "Sunod", + "dictionaryPager": "{from}–{to} sa {total} · Page {page} sa {pages}" } } } diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 5086e72..a0c6dfb 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -479,7 +479,10 @@ "notFound": "Kein Zugriff oder nicht gefunden.", "languageTitle": "Wörterbuch: {name}", "courseTitle": "Kurs-Wörterbuch: {name}", - "courseLearningColumn": "Lernsprache (Kurs)" + "courseLearningColumn": "Lernsprache (Kurs)", + "dictionaryPagerPrev": "Zurück", + "dictionaryPagerNext": "Weiter", + "dictionaryPager": "{from}–{to} von {total} · Seite {page} von {pages}" }, "courses": { "title": "Sprachlernkurse", diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index 6e55879..8c9c7f3 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -479,7 +479,10 @@ "notFound": "No access or not found.", "languageTitle": "Dictionary: {name}", "courseTitle": "Course dictionary: {name}", - "courseLearningColumn": "Learning language (course)" + "courseLearningColumn": "Learning language (course)", + "dictionaryPagerPrev": "Previous", + "dictionaryPagerNext": "Next", + "dictionaryPager": "{from}–{to} of {total} · Page {page} of {pages}" }, "courses": { "title": "Language Learning Courses", diff --git a/frontend/src/i18n/locales/es/socialnetwork.json b/frontend/src/i18n/locales/es/socialnetwork.json index e541262..62452a4 100644 --- a/frontend/src/i18n/locales/es/socialnetwork.json +++ b/frontend/src/i18n/locales/es/socialnetwork.json @@ -477,7 +477,10 @@ "notFound": "Sin acceso o no encontrado.", "languageTitle": "Diccionario: {name}", "courseTitle": "Diccionario del curso: {name}", - "courseLearningColumn": "Idioma de aprendizaje (curso)" + "courseLearningColumn": "Idioma de aprendizaje (curso)", + "dictionaryPagerPrev": "Anterior", + "dictionaryPagerNext": "Siguiente", + "dictionaryPager": "{from}–{to} de {total} · Página {page} de {pages}" }, "courses": { "title": "Cursos de idiomas", diff --git a/frontend/src/i18n/locales/fr/socialnetwork.json b/frontend/src/i18n/locales/fr/socialnetwork.json index 3bd0c3e..c895100 100644 --- a/frontend/src/i18n/locales/fr/socialnetwork.json +++ b/frontend/src/i18n/locales/fr/socialnetwork.json @@ -477,7 +477,10 @@ "notFound": "Pas d’accès ou introuvable.", "languageTitle": "Dictionnaire : {name}", "courseTitle": "Dictionnaire du cours : {name}", - "courseLearningColumn": "Langue apprise (cours)" + "courseLearningColumn": "Langue apprise (cours)", + "dictionaryPagerPrev": "Précédent", + "dictionaryPagerNext": "Suivant", + "dictionaryPager": "{from}–{to} sur {total} · Page {page} sur {pages}" }, "courses": { "title": "Cours d'apprentissage des langues", diff --git a/frontend/src/views/social/VocabDictionaryView.vue b/frontend/src/views/social/VocabDictionaryView.vue index aa79afa..0500ada 100644 --- a/frontend/src/views/social/VocabDictionaryView.vue +++ b/frontend/src/views/social/VocabDictionaryView.vue @@ -26,23 +26,44 @@
{{ $t('general.loading') }}
{{ error }}
-
{{ $t('socialnetwork.vocab.dictionary.empty') }}
- - - - - - - - - - - - - - - -
{{ $t('socialnetwork.vocab.search.motherTongue') }}{{ learningColumnLabel }}{{ $t('socialnetwork.vocab.chapters') }}
{{ row.reference }}{{ row.learning }}{{ row.chapterTitle }}
+
{{ $t('socialnetwork.vocab.dictionary.empty') }}
+
@@ -50,6 +71,8 @@