feat(vocab): implement pagination and localization for vocabulary dictionaries
All checks were successful
Deploy to production / deploy (push) Successful in 3m2s

- Added pagination functionality to the vocabulary dictionary views, allowing users to navigate through results efficiently.
- Introduced a new method `_parseDictionaryPaging` in `VocabService` to handle pagination parameters.
- Updated `getLanguageDictionary` and `getCourseDictionary` methods to return pagination details alongside results.
- Enhanced the `VocabDictionaryView` component with pagination controls and updated UI for better user experience.
- Added localization entries for pagination in Cebuano, German, English, Spanish, and French, ensuring a consistent user experience across languages.
This commit is contained in:
Torsten Schulz (local)
2026-04-10 14:35:50 +02:00
parent f46c864bbc
commit c6419c6c34
7 changed files with 217 additions and 43 deletions

View File

@@ -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 }) {