feat(vocab): implement pagination and localization for vocabulary dictionaries
All checks were successful
Deploy to production / deploy (push) Successful in 3m2s
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:
@@ -34,6 +34,15 @@ export default class VocabService {
|
|||||||
return Math.max(min, Math.min(max, Math.trunc(numeric)));
|
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) {
|
_sanitizeShortString(value, maxLength = 400) {
|
||||||
const text = String(value ?? '').trim();
|
const text = String(value ?? '').trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -1609,13 +1618,39 @@ export default class VocabService {
|
|||||||
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
|
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
|
||||||
* Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE).
|
* 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 user = await this._getUserByHashedId(hashedUserId);
|
||||||
const access = await this._getLanguageAccess(user.id, languageId);
|
const access = await this._getLanguageAccess(user.id, languageId);
|
||||||
const term = typeof q === 'string' ? q.trim() : '';
|
const term = typeof q === 'string' ? q.trim() : '';
|
||||||
const like = term ? `%${term}%` : null;
|
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 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)' : ''}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
let rows = [];
|
||||||
|
if (total > 0) {
|
||||||
|
rows = await sequelize.query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
cl.id,
|
cl.id,
|
||||||
@@ -1630,21 +1665,30 @@ export default class VocabService {
|
|||||||
WHERE c.language_id = :languageId
|
WHERE c.language_id = :languageId
|
||||||
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
|
${like ? 'AND (l1.text ILIKE :like OR l2.text ILIKE :like)' : ''}
|
||||||
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
||||||
LIMIT 20000
|
LIMIT :limit OFFSET :offset
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
replacements: like ? { languageId: access.id, like } : { languageId: access.id },
|
replacements: { ...baseReplacements, limit: pageSize, offset },
|
||||||
type: sequelize.QueryTypes.SELECT,
|
type: sequelize.QueryTypes.SELECT,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return { languageId: access.id, results: rows };
|
return {
|
||||||
|
languageId: access.id,
|
||||||
|
results: rows,
|
||||||
|
total,
|
||||||
|
page: effectivePage,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten).
|
* 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 pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId);
|
||||||
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
|
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
|
||||||
let vocabs = pool.vocabs || [];
|
let vocabs = pool.vocabs || [];
|
||||||
@@ -1660,7 +1704,20 @@ export default class VocabService {
|
|||||||
if (refCmp !== 0) return refCmp;
|
if (refCmp !== 0) return refCmp;
|
||||||
return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' });
|
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 }) {
|
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
||||||
|
|||||||
@@ -802,7 +802,10 @@
|
|||||||
"notFound": "Walay access o wala makita.",
|
"notFound": "Walay access o wala makita.",
|
||||||
"languageTitle": "Dictionary: {name}",
|
"languageTitle": "Dictionary: {name}",
|
||||||
"courseTitle": "Dictionary sa kurso: {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}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,7 +479,10 @@
|
|||||||
"notFound": "Kein Zugriff oder nicht gefunden.",
|
"notFound": "Kein Zugriff oder nicht gefunden.",
|
||||||
"languageTitle": "Wörterbuch: {name}",
|
"languageTitle": "Wörterbuch: {name}",
|
||||||
"courseTitle": "Kurs-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": {
|
"courses": {
|
||||||
"title": "Sprachlernkurse",
|
"title": "Sprachlernkurse",
|
||||||
|
|||||||
@@ -479,7 +479,10 @@
|
|||||||
"notFound": "No access or not found.",
|
"notFound": "No access or not found.",
|
||||||
"languageTitle": "Dictionary: {name}",
|
"languageTitle": "Dictionary: {name}",
|
||||||
"courseTitle": "Course 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": {
|
"courses": {
|
||||||
"title": "Language Learning Courses",
|
"title": "Language Learning Courses",
|
||||||
|
|||||||
@@ -477,7 +477,10 @@
|
|||||||
"notFound": "Sin acceso o no encontrado.",
|
"notFound": "Sin acceso o no encontrado.",
|
||||||
"languageTitle": "Diccionario: {name}",
|
"languageTitle": "Diccionario: {name}",
|
||||||
"courseTitle": "Diccionario del curso: {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": {
|
"courses": {
|
||||||
"title": "Cursos de idiomas",
|
"title": "Cursos de idiomas",
|
||||||
|
|||||||
@@ -477,7 +477,10 @@
|
|||||||
"notFound": "Pas d’accès ou introuvable.",
|
"notFound": "Pas d’accès ou introuvable.",
|
||||||
"languageTitle": "Dictionnaire : {name}",
|
"languageTitle": "Dictionnaire : {name}",
|
||||||
"courseTitle": "Dictionnaire du cours : {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": {
|
"courses": {
|
||||||
"title": "Cours d'apprentissage des langues",
|
"title": "Cours d'apprentissage des langues",
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
<div class="dictionary-body surface-card">
|
<div class="dictionary-body surface-card">
|
||||||
<div v-if="listLoading" class="dictionary-state">{{ $t('general.loading') }}</div>
|
<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="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>
|
<div v-else-if="dictionaryTotal === 0" class="dictionary-state">{{ $t('socialnetwork.vocab.dictionary.empty') }}</div>
|
||||||
<table v-else class="dictionary-table">
|
<template v-else>
|
||||||
|
<table class="dictionary-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $t('socialnetwork.vocab.search.motherTongue') }}</th>
|
<th>{{ $t('socialnetwork.vocab.search.motherTongue') }}</th>
|
||||||
@@ -43,6 +44,26 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,6 +71,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
|
const DICTIONARY_PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'VocabDictionaryView',
|
name: 'VocabDictionaryView',
|
||||||
data() {
|
data() {
|
||||||
@@ -60,6 +83,10 @@ export default {
|
|||||||
languageName: '',
|
languageName: '',
|
||||||
q: '',
|
q: '',
|
||||||
results: [],
|
results: [],
|
||||||
|
dictionaryPage: 1,
|
||||||
|
dictionaryTotal: 0,
|
||||||
|
dictionaryTotalPages: 1,
|
||||||
|
dictionaryPageSize: DICTIONARY_PAGE_SIZE,
|
||||||
error: '',
|
error: '',
|
||||||
debounceId: null,
|
debounceId: null,
|
||||||
};
|
};
|
||||||
@@ -81,6 +108,18 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
|
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: {
|
watch: {
|
||||||
'$route.fullPath'() {
|
'$route.fullPath'() {
|
||||||
@@ -99,6 +138,9 @@ export default {
|
|||||||
async bootstrap() {
|
async bootstrap() {
|
||||||
this.q = '';
|
this.q = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
|
this.dictionaryPage = 1;
|
||||||
|
this.dictionaryTotal = 0;
|
||||||
|
this.dictionaryTotalPages = 1;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
await this.loadContext();
|
await this.loadContext();
|
||||||
if (!this.contextTitle) {
|
if (!this.contextTitle) {
|
||||||
@@ -112,9 +154,22 @@ export default {
|
|||||||
}
|
}
|
||||||
this.debounceId = setTimeout(() => {
|
this.debounceId = setTimeout(() => {
|
||||||
this.debounceId = null;
|
this.debounceId = null;
|
||||||
|
this.dictionaryPage = 1;
|
||||||
this.loadList();
|
this.loadList();
|
||||||
}, 320);
|
}, 320);
|
||||||
},
|
},
|
||||||
|
dictionaryGoPrev() {
|
||||||
|
if (this.dictionaryPage > 1) {
|
||||||
|
this.dictionaryPage -= 1;
|
||||||
|
this.loadList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dictionaryGoNext() {
|
||||||
|
if (this.dictionaryPage < this.dictionaryTotalPages) {
|
||||||
|
this.dictionaryPage += 1;
|
||||||
|
this.loadList();
|
||||||
|
}
|
||||||
|
},
|
||||||
async loadContext() {
|
async loadContext() {
|
||||||
this.metaLoading = true;
|
this.metaLoading = true;
|
||||||
this.contextTitle = '';
|
this.contextTitle = '';
|
||||||
@@ -148,17 +203,27 @@ export default {
|
|||||||
this.listLoading = true;
|
this.listLoading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
const params = { q: this.q.trim() };
|
const params = {
|
||||||
if (this.isCourseMode) {
|
q: this.q.trim(),
|
||||||
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}/dictionary`, { params });
|
page: this.dictionaryPage,
|
||||||
this.results = data?.results || [];
|
pageSize: DICTIONARY_PAGE_SIZE,
|
||||||
} else {
|
};
|
||||||
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}/dictionary`, { params });
|
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.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) {
|
} catch (e) {
|
||||||
console.error('Vocab dictionary list failed:', e);
|
console.error('Vocab dictionary list failed:', e);
|
||||||
this.results = [];
|
this.results = [];
|
||||||
|
this.dictionaryTotal = 0;
|
||||||
|
this.dictionaryTotalPages = 1;
|
||||||
this.error = this.$t('socialnetwork.vocab.dictionary.loadError');
|
this.error = this.$t('socialnetwork.vocab.dictionary.loadError');
|
||||||
} finally {
|
} finally {
|
||||||
this.listLoading = false;
|
this.listLoading = false;
|
||||||
@@ -250,4 +315,41 @@ export default {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 0.85rem;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user