feat(vocab): add language and course dictionary endpoints and UI components
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s
- Implemented `getLanguageDictionary` and `getCourseDictionary` methods in the VocabService to retrieve vocabulary entries filtered by search terms. - Updated VocabController and vocabRouter to include new routes for accessing language and course dictionaries. - Enhanced frontend components to navigate to the new dictionary views, including buttons in VocabCourseView and VocabLanguageView. - Added localization entries for the dictionary feature in multiple languages, ensuring a consistent user experience across the platform.
This commit is contained in:
@@ -18,6 +18,9 @@ class VocabController {
|
|||||||
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
|
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
|
||||||
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
|
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
|
||||||
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
|
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
|
||||||
|
this.getLanguageDictionary = this._wrapWithUser((userId, req) =>
|
||||||
|
this.service.getLanguageDictionary(userId, req.params.languageId, req.query)
|
||||||
|
);
|
||||||
|
|
||||||
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
|
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
|
||||||
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
|
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));
|
||||||
@@ -31,6 +34,9 @@ class VocabController {
|
|||||||
this.getCompletedLessonVocabPool = this._wrapWithUser((userId, req) =>
|
this.getCompletedLessonVocabPool = this._wrapWithUser((userId, req) =>
|
||||||
this.service.getCompletedLessonVocabPool(userId, req.params.courseId, req.query.untilLessonId)
|
this.service.getCompletedLessonVocabPool(userId, req.params.courseId, req.query.untilLessonId)
|
||||||
);
|
);
|
||||||
|
this.getCourseDictionary = this._wrapWithUser((userId, req) =>
|
||||||
|
this.service.getCourseDictionary(userId, req.params.courseId, req.query)
|
||||||
|
);
|
||||||
this.getVocabDistractorPool = this._wrapWithUser((userId, req) =>
|
this.getVocabDistractorPool = this._wrapWithUser((userId, req) =>
|
||||||
this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId)
|
this.service.getVocabDistractorPool(userId, req.params.courseId, req.query.beforeLessonId)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ router.get('/languages/:languageId/chapters', vocabController.listChapters);
|
|||||||
router.post('/languages/:languageId/chapters', vocabController.createChapter);
|
router.post('/languages/:languageId/chapters', vocabController.createChapter);
|
||||||
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
|
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
|
||||||
router.get('/languages/:languageId/search', vocabController.searchVocabs);
|
router.get('/languages/:languageId/search', vocabController.searchVocabs);
|
||||||
|
router.get('/languages/:languageId/dictionary', vocabController.getLanguageDictionary);
|
||||||
|
|
||||||
router.get('/chapters/:chapterId', vocabController.getChapter);
|
router.get('/chapters/:chapterId', vocabController.getChapter);
|
||||||
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
||||||
@@ -32,6 +33,7 @@ router.get('/courses', vocabController.getCourses);
|
|||||||
router.get('/courses/my', vocabController.getMyCourses);
|
router.get('/courses/my', vocabController.getMyCourses);
|
||||||
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
|
router.post('/courses/find-by-code', vocabController.getCourseByShareCode);
|
||||||
router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getCompletedLessonVocabPool);
|
router.get('/courses/:courseId/completed-lesson-vocabs', vocabController.getCompletedLessonVocabPool);
|
||||||
|
router.get('/courses/:courseId/dictionary', vocabController.getCourseDictionary);
|
||||||
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
|
router.get('/courses/:courseId/distractor-pool', vocabController.getVocabDistractorPool);
|
||||||
router.get('/courses/:courseId', vocabController.getCourse);
|
router.get('/courses/:courseId', vocabController.getCourse);
|
||||||
router.put('/courses/:courseId', vocabController.updateCourse);
|
router.put('/courses/:courseId', vocabController.updateCourse);
|
||||||
|
|||||||
@@ -1605,6 +1605,64 @@ export default class VocabService {
|
|||||||
return { languageId: access.id, results: rows };
|
return { languageId: access.id, results: rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wörterbuch: alle Vokabeln einer Trainer-Sprache (Kapitel), optional gefiltert.
|
||||||
|
* Ein Suchbegriff durchsucht Lern- und Referenzspalte (Teilstrings, ILIKE).
|
||||||
|
*/
|
||||||
|
async getLanguageDictionary(hashedUserId, languageId, { q } = {}) {
|
||||||
|
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 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 20000
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
replacements: like ? { languageId: access.id, like } : { languageId: access.id },
|
||||||
|
type: sequelize.QueryTypes.SELECT,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return { languageId: access.id, results: rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wörterbuch: aus abgeschlossenen Kurslektionen extrahierte Paare, optional gefiltert (Teilstring in beiden Spalten).
|
||||||
|
*/
|
||||||
|
async getCourseDictionary(hashedUserId, courseId, { q } = {}) {
|
||||||
|
const pool = await this.getCompletedLessonVocabPool(hashedUserId, courseId);
|
||||||
|
const term = typeof q === 'string' ? q.trim().toLowerCase() : '';
|
||||||
|
let vocabs = pool.vocabs || [];
|
||||||
|
if (term) {
|
||||||
|
vocabs = vocabs.filter((entry) => {
|
||||||
|
const l = String(entry.learning || '').toLowerCase();
|
||||||
|
const r = String(entry.reference || '').toLowerCase();
|
||||||
|
return l.includes(term) || r.includes(term);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vocabs.sort((a, b) => {
|
||||||
|
const refCmp = String(a.reference || '').localeCompare(String(b.reference || ''), undefined, { sensitivity: 'base' });
|
||||||
|
if (refCmp !== 0) return refCmp;
|
||||||
|
return String(a.learning || '').localeCompare(String(b.learning || ''), undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
return { courseId: pool.courseId, results: vocabs };
|
||||||
|
}
|
||||||
|
|
||||||
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
||||||
const user = await this._getUserByHashedId(hashedUserId);
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
const ch = await this._getChapterAccess(user.id, chapterId);
|
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ const TITLE_MAP = {
|
|||||||
VocabNewLanguage: 'sectionBar.titles.newLanguage',
|
VocabNewLanguage: 'sectionBar.titles.newLanguage',
|
||||||
VocabSubscribe: 'sectionBar.titles.subscribeLanguage',
|
VocabSubscribe: 'sectionBar.titles.subscribeLanguage',
|
||||||
VocabLanguage: 'sectionBar.titles.language',
|
VocabLanguage: 'sectionBar.titles.language',
|
||||||
|
VocabLanguageDictionary: 'sectionBar.titles.vocabDictionary',
|
||||||
VocabChapter: 'sectionBar.titles.chapter',
|
VocabChapter: 'sectionBar.titles.chapter',
|
||||||
VocabCourses: 'sectionBar.titles.courses',
|
VocabCourses: 'sectionBar.titles.courses',
|
||||||
|
VocabCourseDictionary: 'sectionBar.titles.vocabDictionary',
|
||||||
VocabCourse: 'sectionBar.titles.course',
|
VocabCourse: 'sectionBar.titles.course',
|
||||||
VocabLesson: 'sectionBar.titles.lesson',
|
VocabLesson: 'sectionBar.titles.lesson',
|
||||||
FalukantCreate: 'sectionBar.titles.createCharacter',
|
FalukantCreate: 'sectionBar.titles.createCharacter',
|
||||||
@@ -102,6 +104,14 @@ export default {
|
|||||||
backTarget() {
|
backTarget() {
|
||||||
const params = this.$route?.params || {};
|
const params = this.$route?.params || {};
|
||||||
|
|
||||||
|
if (this.routePath.endsWith('/dictionary') && params.courseId) {
|
||||||
|
return `/socialnetwork/vocab/courses/${params.courseId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.routePath.endsWith('/dictionary') && params.languageId) {
|
||||||
|
return `/socialnetwork/vocab/${params.languageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
|
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
|
||||||
return `/socialnetwork/vocab/courses/${params.courseId}`;
|
return `/socialnetwork/vocab/courses/${params.courseId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
"subscribeLanguage": "Mag-subscribe sa pinulongan",
|
"subscribeLanguage": "Mag-subscribe sa pinulongan",
|
||||||
"language": "Pinulongan",
|
"language": "Pinulongan",
|
||||||
"chapter": "Kapitulo",
|
"chapter": "Kapitulo",
|
||||||
|
"vocabDictionary": "Dictionary",
|
||||||
"courses": "Mga kurso",
|
"courses": "Mga kurso",
|
||||||
"course": "Kurso",
|
"course": "Kurso",
|
||||||
"lesson": "Leksiyon",
|
"lesson": "Leksiyon",
|
||||||
|
|||||||
@@ -785,6 +785,19 @@
|
|||||||
"search": "Pangita",
|
"search": "Pangita",
|
||||||
"noResults": "Walay results.",
|
"noResults": "Walay results.",
|
||||||
"error": "Pangita failed."
|
"error": "Pangita failed."
|
||||||
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"open": "Dictionary",
|
||||||
|
"kicker": "Tan-awa",
|
||||||
|
"intro": "Tanang termino gikan sa imong mga kapitulo. Usa ka field para sa partial match sa duha ka pinulongan.",
|
||||||
|
"searchLabel": "Pangita (duha ka pinulongan)",
|
||||||
|
"searchPlaceholder": "Part sa pulong sa bisan unsang pinulongan …",
|
||||||
|
"empty": "Walay entry.",
|
||||||
|
"loadError": "Dili ma-load ang dictionary.",
|
||||||
|
"notFound": "Walay access o wala makita.",
|
||||||
|
"languageTitle": "Dictionary: {name}",
|
||||||
|
"courseTitle": "Dictionary sa kurso: {name}",
|
||||||
|
"courseLearningColumn": "Pagkat-on nga pinulongan (kurso)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
"subscribeLanguage": "Sprache abonnieren",
|
"subscribeLanguage": "Sprache abonnieren",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"chapter": "Kapitel",
|
"chapter": "Kapitel",
|
||||||
|
"vocabDictionary": "Wörterbuch",
|
||||||
"courses": "Kurse",
|
"courses": "Kurse",
|
||||||
"course": "Kurs",
|
"course": "Kurs",
|
||||||
"lesson": "Lektion",
|
"lesson": "Lektion",
|
||||||
|
|||||||
@@ -468,6 +468,19 @@
|
|||||||
"noResults": "Keine Treffer.",
|
"noResults": "Keine Treffer.",
|
||||||
"error": "Suche fehlgeschlagen."
|
"error": "Suche fehlgeschlagen."
|
||||||
},
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"open": "Wörterbuch",
|
||||||
|
"kicker": "Nachschlagen",
|
||||||
|
"intro": "Alle Begriffe aus deinen Kapiteln. Ein Suchfeld filtert in beiden Sprachen nach Teilwörtern.",
|
||||||
|
"searchLabel": "Suche (beide Sprachen)",
|
||||||
|
"searchPlaceholder": "Teilwort in Lern- oder Muttersprache …",
|
||||||
|
"empty": "Keine Einträge.",
|
||||||
|
"loadError": "Wörterbuch konnte nicht geladen werden.",
|
||||||
|
"notFound": "Kein Zugriff oder nicht gefunden.",
|
||||||
|
"languageTitle": "Wörterbuch: {name}",
|
||||||
|
"courseTitle": "Kurs-Wörterbuch: {name}",
|
||||||
|
"courseLearningColumn": "Lernsprache (Kurs)"
|
||||||
|
},
|
||||||
"courses": {
|
"courses": {
|
||||||
"title": "Sprachlernkurse",
|
"title": "Sprachlernkurse",
|
||||||
"create": "Kurs erstellen",
|
"create": "Kurs erstellen",
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
"subscribeLanguage": "Subscribe to language",
|
"subscribeLanguage": "Subscribe to language",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"chapter": "Chapter",
|
"chapter": "Chapter",
|
||||||
|
"vocabDictionary": "Dictionary",
|
||||||
"courses": "Courses",
|
"courses": "Courses",
|
||||||
"course": "Course",
|
"course": "Course",
|
||||||
"lesson": "Lesson",
|
"lesson": "Lesson",
|
||||||
|
|||||||
@@ -468,6 +468,19 @@
|
|||||||
"noResults": "No results.",
|
"noResults": "No results.",
|
||||||
"error": "Search failed."
|
"error": "Search failed."
|
||||||
},
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"open": "Dictionary",
|
||||||
|
"kicker": "Lookup",
|
||||||
|
"intro": "All terms from your chapters. One field filters both languages by partial matches.",
|
||||||
|
"searchLabel": "Search (both languages)",
|
||||||
|
"searchPlaceholder": "Part of a word in either language…",
|
||||||
|
"empty": "No entries.",
|
||||||
|
"loadError": "Could not load the dictionary.",
|
||||||
|
"notFound": "No access or not found.",
|
||||||
|
"languageTitle": "Dictionary: {name}",
|
||||||
|
"courseTitle": "Course dictionary: {name}",
|
||||||
|
"courseLearningColumn": "Learning language (course)"
|
||||||
|
},
|
||||||
"courses": {
|
"courses": {
|
||||||
"title": "Language Learning Courses",
|
"title": "Language Learning Courses",
|
||||||
"create": "Create Course",
|
"create": "Create Course",
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
"subscribeLanguage": "Suscribirse al idioma",
|
"subscribeLanguage": "Suscribirse al idioma",
|
||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"chapter": "Capítulo",
|
"chapter": "Capítulo",
|
||||||
|
"vocabDictionary": "Diccionario",
|
||||||
"courses": "Cursos",
|
"courses": "Cursos",
|
||||||
"course": "Curso",
|
"course": "Curso",
|
||||||
"lesson": "Lección",
|
"lesson": "Lección",
|
||||||
|
|||||||
@@ -466,6 +466,19 @@
|
|||||||
"noResults": "Sin resultados.",
|
"noResults": "Sin resultados.",
|
||||||
"error": "La búsqueda ha fallado."
|
"error": "La búsqueda ha fallado."
|
||||||
},
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"open": "Diccionario",
|
||||||
|
"kicker": "Consulta",
|
||||||
|
"intro": "Todos los términos de tus capítulos. Un solo campo filtra en ambos idiomas por coincidencias parciales.",
|
||||||
|
"searchLabel": "Búsqueda (ambos idiomas)",
|
||||||
|
"searchPlaceholder": "Parte de una palabra en cualquier idioma…",
|
||||||
|
"empty": "Sin entradas.",
|
||||||
|
"loadError": "No se pudo cargar el diccionario.",
|
||||||
|
"notFound": "Sin acceso o no encontrado.",
|
||||||
|
"languageTitle": "Diccionario: {name}",
|
||||||
|
"courseTitle": "Diccionario del curso: {name}",
|
||||||
|
"courseLearningColumn": "Idioma de aprendizaje (curso)"
|
||||||
|
},
|
||||||
"courses": {
|
"courses": {
|
||||||
"title": "Cursos de idiomas",
|
"title": "Cursos de idiomas",
|
||||||
"create": "Crear curso",
|
"create": "Crear curso",
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
"subscribeLanguage": "Abonnez-vous à la langue",
|
"subscribeLanguage": "Abonnez-vous à la langue",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"chapter": "Chapitre",
|
"chapter": "Chapitre",
|
||||||
|
"vocabDictionary": "Dictionnaire",
|
||||||
"courses": "Cours",
|
"courses": "Cours",
|
||||||
"course": "Kurs",
|
"course": "Kurs",
|
||||||
"lesson": "Lektion",
|
"lesson": "Lektion",
|
||||||
|
|||||||
@@ -466,6 +466,19 @@
|
|||||||
"noResults": "Aucun coup sûr.",
|
"noResults": "Aucun coup sûr.",
|
||||||
"error": "La recherche a échoué."
|
"error": "La recherche a échoué."
|
||||||
},
|
},
|
||||||
|
"dictionary": {
|
||||||
|
"open": "Dictionnaire",
|
||||||
|
"kicker": "Consulter",
|
||||||
|
"intro": "Tous les termes de vos chapitres. Un seul champ filtre les deux langues par correspondances partielles.",
|
||||||
|
"searchLabel": "Recherche (les deux langues)",
|
||||||
|
"searchPlaceholder": "Fragment dans l’une ou l’autre langue…",
|
||||||
|
"empty": "Aucune entrée.",
|
||||||
|
"loadError": "Impossible de charger le dictionnaire.",
|
||||||
|
"notFound": "Pas d’accès ou introuvable.",
|
||||||
|
"languageTitle": "Dictionnaire : {name}",
|
||||||
|
"courseTitle": "Dictionnaire du cours : {name}",
|
||||||
|
"courseLearningColumn": "Langue apprise (cours)"
|
||||||
|
},
|
||||||
"courses": {
|
"courses": {
|
||||||
"title": "Cours d'apprentissage des langues",
|
"title": "Cours d'apprentissage des langues",
|
||||||
"create": "Créer un cours",
|
"create": "Créer un cours",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const VocabCourseListView = () => import('../views/social/VocabCourseListView.vu
|
|||||||
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
||||||
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
||||||
const VocabLessonReviewView = () => import('../views/social/VocabLessonReviewView.vue');
|
const VocabLessonReviewView = () => import('../views/social/VocabLessonReviewView.vue');
|
||||||
|
const VocabDictionaryView = () => import('../views/social/VocabDictionaryView.vue');
|
||||||
const EroticAccessView = () => import('../views/social/EroticAccessView.vue');
|
const EroticAccessView = () => import('../views/social/EroticAccessView.vue');
|
||||||
const EroticPicturesView = () => import('../views/social/EroticPicturesView.vue');
|
const EroticPicturesView = () => import('../views/social/EroticPicturesView.vue');
|
||||||
const EroticVideosView = () => import('../views/social/EroticVideosView.vue');
|
const EroticVideosView = () => import('../views/social/EroticVideosView.vue');
|
||||||
@@ -97,18 +98,6 @@ const socialRoutes = [
|
|||||||
component: VocabSubscribeView,
|
component: VocabSubscribeView,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/socialnetwork/vocab/:languageId',
|
|
||||||
name: 'VocabLanguage',
|
|
||||||
component: VocabLanguageView,
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
|
|
||||||
name: 'VocabChapter',
|
|
||||||
component: VocabChapterView,
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/socialnetwork/vocab/courses',
|
path: '/socialnetwork/vocab/courses',
|
||||||
name: 'VocabCourses',
|
name: 'VocabCourses',
|
||||||
@@ -116,10 +105,9 @@ const socialRoutes = [
|
|||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/socialnetwork/vocab/courses/:courseId',
|
path: '/socialnetwork/vocab/courses/:courseId/dictionary',
|
||||||
name: 'VocabCourse',
|
name: 'VocabCourseDictionary',
|
||||||
component: VocabCourseView,
|
component: VocabDictionaryView,
|
||||||
props: true,
|
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,6 +124,31 @@ const socialRoutes = [
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/socialnetwork/vocab/courses/:courseId',
|
||||||
|
name: 'VocabCourse',
|
||||||
|
component: VocabCourseView,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/socialnetwork/vocab/:languageId/dictionary',
|
||||||
|
name: 'VocabLanguageDictionary',
|
||||||
|
component: VocabDictionaryView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
|
||||||
|
name: 'VocabChapter',
|
||||||
|
component: VocabChapterView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/socialnetwork/vocab/:languageId',
|
||||||
|
name: 'VocabLanguage',
|
||||||
|
component: VocabLanguageView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default socialRoutes;
|
export default socialRoutes;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
<span v-if="course.shareCode && isOwner" class="share-code">
|
<span v-if="course.shareCode && isOwner" class="share-code">
|
||||||
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>
|
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>
|
||||||
</span>
|
</span>
|
||||||
|
<button type="button" class="course-dictionary-link" @click="goCourseDictionary">
|
||||||
|
{{ $t('socialnetwork.vocab.dictionary.open') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="surface-card course-assistant">
|
<section class="surface-card course-assistant">
|
||||||
@@ -691,6 +694,9 @@ export default {
|
|||||||
showApiError(this, e, this.$t('socialnetwork.vocab.courses.deleteLessonError'));
|
showApiError(this, e, this.$t('socialnetwork.vocab.courses.deleteLessonError'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
goCourseDictionary() {
|
||||||
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/dictionary`);
|
||||||
|
},
|
||||||
openLesson(lessonId) {
|
openLesson(lessonId) {
|
||||||
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
|
||||||
},
|
},
|
||||||
@@ -826,6 +832,11 @@ export default {
|
|||||||
color: #666;
|
color: #666;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 16px 18px;
|
padding: 16px 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-dictionary-link {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-assistant {
|
.course-assistant {
|
||||||
|
|||||||
253
frontend/src/views/social/VocabDictionaryView.vue
Normal file
253
frontend/src/views/social/VocabDictionaryView.vue
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vocab-dictionary-view">
|
||||||
|
<section class="dictionary-hero surface-card">
|
||||||
|
<div v-if="metaLoading">{{ $t('general.loading') }}</div>
|
||||||
|
<div v-else-if="!contextTitle">{{ $t('socialnetwork.vocab.dictionary.notFound') }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<span class="dictionary-kicker">{{ $t('socialnetwork.vocab.dictionary.kicker') }}</span>
|
||||||
|
<h2>{{ pageHeading }}</h2>
|
||||||
|
<p>{{ $t('socialnetwork.vocab.dictionary.intro') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="contextTitle" class="dictionary-toolbar surface-card">
|
||||||
|
<label class="dictionary-search">
|
||||||
|
<span class="dictionary-search__label">{{ $t('socialnetwork.vocab.dictionary.searchLabel') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="q"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="$t('socialnetwork.vocab.dictionary.searchPlaceholder')"
|
||||||
|
@input="scheduleLoad"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import apiClient from '@/utils/axios.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'VocabDictionaryView',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
metaLoading: false,
|
||||||
|
listLoading: false,
|
||||||
|
contextTitle: '',
|
||||||
|
languageName: '',
|
||||||
|
q: '',
|
||||||
|
results: [],
|
||||||
|
error: '',
|
||||||
|
debounceId: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isCourseMode() {
|
||||||
|
return Boolean(this.$route.params.courseId);
|
||||||
|
},
|
||||||
|
pageHeading() {
|
||||||
|
if (!this.contextTitle) return '';
|
||||||
|
if (this.isCourseMode) {
|
||||||
|
return this.$t('socialnetwork.vocab.dictionary.courseTitle', { name: this.contextTitle });
|
||||||
|
}
|
||||||
|
return this.$t('socialnetwork.vocab.dictionary.languageTitle', { name: this.contextTitle });
|
||||||
|
},
|
||||||
|
learningColumnLabel() {
|
||||||
|
if (this.isCourseMode) {
|
||||||
|
return this.$t('socialnetwork.vocab.dictionary.courseLearningColumn');
|
||||||
|
}
|
||||||
|
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.fullPath'() {
|
||||||
|
this.bootstrap();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.bootstrap();
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.debounceId) {
|
||||||
|
clearTimeout(this.debounceId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async bootstrap() {
|
||||||
|
this.q = '';
|
||||||
|
this.results = [];
|
||||||
|
this.error = '';
|
||||||
|
await this.loadContext();
|
||||||
|
if (!this.contextTitle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadList();
|
||||||
|
},
|
||||||
|
scheduleLoad() {
|
||||||
|
if (this.debounceId) {
|
||||||
|
clearTimeout(this.debounceId);
|
||||||
|
}
|
||||||
|
this.debounceId = setTimeout(() => {
|
||||||
|
this.debounceId = null;
|
||||||
|
this.loadList();
|
||||||
|
}, 320);
|
||||||
|
},
|
||||||
|
async loadContext() {
|
||||||
|
this.metaLoading = true;
|
||||||
|
this.contextTitle = '';
|
||||||
|
this.languageName = '';
|
||||||
|
try {
|
||||||
|
if (this.isCourseMode) {
|
||||||
|
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}`);
|
||||||
|
this.contextTitle = data?.title || '';
|
||||||
|
} else {
|
||||||
|
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
|
||||||
|
this.contextTitle = data?.name || '';
|
||||||
|
this.languageName = data?.name || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Vocab dictionary context load failed:', e);
|
||||||
|
this.contextTitle = '';
|
||||||
|
} finally {
|
||||||
|
this.metaLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadList() {
|
||||||
|
if (this.metaLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hasScope = this.isCourseMode
|
||||||
|
? Boolean(this.$route.params.courseId)
|
||||||
|
: Boolean(this.$route.params.languageId);
|
||||||
|
if (!hasScope) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Vocab dictionary list failed:', e);
|
||||||
|
this.results = [];
|
||||||
|
this.error = this.$t('socialnetwork.vocab.dictionary.loadError');
|
||||||
|
} finally {
|
||||||
|
this.listLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vocab-dictionary-view {
|
||||||
|
max-width: var(--content-max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-hero {
|
||||||
|
padding: 24px 26px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-kicker {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(248, 162, 43, 0.14);
|
||||||
|
color: #8a5411;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-toolbar {
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-search {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-search__label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-search input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-state {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-state--error {
|
||||||
|
color: var(--color-danger, #b42318);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-table th,
|
||||||
|
.dictionary-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dictionary-table th {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<div class="row row--actions">
|
<div class="row row--actions">
|
||||||
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
|
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
|
||||||
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
||||||
|
<button type="button" @click="goDictionary">{{ $t('socialnetwork.vocab.dictionary.open') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -85,6 +86,9 @@ export default {
|
|||||||
languageName: this.language?.name || '',
|
languageName: this.language?.name || '',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
goDictionary() {
|
||||||
|
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/dictionary`);
|
||||||
|
},
|
||||||
openChapter(chapterId) {
|
openChapter(chapterId) {
|
||||||
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
|
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user