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') }}
+
+
+
+
+ | {{ $t('socialnetwork.vocab.search.motherTongue') }} |
+ {{ learningColumnLabel }} |
+ {{ $t('socialnetwork.vocab.chapters') }} |
+
+
+
+
+ | {{ row.reference }} |
+ {{ row.learning }} |
+ {{ row.chapterTitle }} |
+
+
+
+
+
@@ -50,6 +71,8 @@