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

View File

@@ -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}"
}
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -477,7 +477,10 @@
"notFound": "Pas daccè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",

View File

@@ -26,23 +26,44 @@
<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 v-else-if="dictionaryTotal === 0" class="dictionary-state">{{ $t('socialnetwork.vocab.dictionary.empty') }}</div>
<template v-else>
<table 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 v-if="dictionaryTotalPages > 1" class="dictionary-pager">
<button
type="button"
class="dictionary-pager__btn"
:disabled="dictionaryPage <= 1"
@click="dictionaryGoPrev"
>
{{ $t('socialnetwork.vocab.dictionary.dictionaryPagerPrev') }}
</button>
<span class="dictionary-pager__meta">{{ $t('socialnetwork.vocab.dictionary.dictionaryPager', dictionaryPagerMeta) }}</span>
<button
type="button"
class="dictionary-pager__btn"
:disabled="dictionaryPage >= dictionaryTotalPages"
@click="dictionaryGoNext"
>
{{ $t('socialnetwork.vocab.dictionary.dictionaryPagerNext') }}
</button>
</div>
</template>
</div>
</div>
</template>
@@ -50,6 +71,8 @@
<script>
import apiClient from '@/utils/axios.js';
const DICTIONARY_PAGE_SIZE = 25;
export default {
name: 'VocabDictionaryView',
data() {
@@ -60,6 +83,10 @@ export default {
languageName: '',
q: '',
results: [],
dictionaryPage: 1,
dictionaryTotal: 0,
dictionaryTotalPages: 1,
dictionaryPageSize: DICTIONARY_PAGE_SIZE,
error: '',
debounceId: null,
};
@@ -81,6 +108,18 @@ export default {
}
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
},
dictionaryPagerMeta() {
const n = this.dictionaryTotal;
const totalPages = this.dictionaryTotalPages;
const page = this.dictionaryPage;
const pageSize = this.dictionaryPageSize || DICTIONARY_PAGE_SIZE;
if (n === 0) {
return { from: 0, to: 0, page: 1, pages: 1, total: 0 };
}
const from = (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, n);
return { from, to, page, pages: totalPages, total: n };
},
},
watch: {
'$route.fullPath'() {
@@ -99,6 +138,9 @@ export default {
async bootstrap() {
this.q = '';
this.results = [];
this.dictionaryPage = 1;
this.dictionaryTotal = 0;
this.dictionaryTotalPages = 1;
this.error = '';
await this.loadContext();
if (!this.contextTitle) {
@@ -112,9 +154,22 @@ export default {
}
this.debounceId = setTimeout(() => {
this.debounceId = null;
this.dictionaryPage = 1;
this.loadList();
}, 320);
},
dictionaryGoPrev() {
if (this.dictionaryPage > 1) {
this.dictionaryPage -= 1;
this.loadList();
}
},
dictionaryGoNext() {
if (this.dictionaryPage < this.dictionaryTotalPages) {
this.dictionaryPage += 1;
this.loadList();
}
},
async loadContext() {
this.metaLoading = true;
this.contextTitle = '';
@@ -148,17 +203,27 @@ export default {
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 || [];
const params = {
q: this.q.trim(),
page: this.dictionaryPage,
pageSize: DICTIONARY_PAGE_SIZE,
};
const url = this.isCourseMode
? `/api/vocab/courses/${this.$route.params.courseId}/dictionary`
: `/api/vocab/languages/${this.$route.params.languageId}/dictionary`;
const { data } = await apiClient.get(url, { params });
this.results = data?.results || [];
this.dictionaryTotal = typeof data?.total === 'number' ? data.total : this.results.length;
this.dictionaryTotalPages = typeof data?.totalPages === 'number' ? data.totalPages : 1;
this.dictionaryPageSize = typeof data?.pageSize === 'number' ? data.pageSize : DICTIONARY_PAGE_SIZE;
if (typeof data?.page === 'number') {
this.dictionaryPage = data.page;
}
} catch (e) {
console.error('Vocab dictionary list failed:', e);
this.results = [];
this.dictionaryTotal = 0;
this.dictionaryTotalPages = 1;
this.error = this.$t('socialnetwork.vocab.dictionary.loadError');
} finally {
this.listLoading = false;
@@ -250,4 +315,41 @@ export default {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.dictionary-pager {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px 16px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--color-border);
}
.dictionary-pager__btn {
padding: 6px 14px;
font-size: 0.9rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-elevated, #faf8f5);
color: var(--color-text);
cursor: pointer;
}
.dictionary-pager__btn:hover:not(:disabled) {
filter: brightness(0.97);
}
.dictionary-pager__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.dictionary-pager__meta {
font-size: 0.88rem;
color: var(--color-text-secondary);
text-align: center;
min-width: min(100%, 14rem);
}
</style>