diff --git a/backend/controllers/vocabController.js b/backend/controllers/vocabController.js index cefa05a..6b2c666 100644 --- a/backend/controllers/vocabController.js +++ b/backend/controllers/vocabController.js @@ -26,6 +26,7 @@ class VocabController { this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 }); this.getCourses = this._wrapWithUser((userId, req) => this.service.getCourses(userId, req.query)); this.getCourse = this._wrapWithUser((userId, req) => this.service.getCourse(userId, req.params.courseId)); + this.getCourseByShareCode = this._wrapWithUser((userId, req) => this.service.getCourseByShareCode(userId, req.body.shareCode)); this.updateCourse = this._wrapWithUser((userId, req) => this.service.updateCourse(userId, req.params.courseId, req.body)); this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId)); diff --git a/backend/routers/vocabRouter.js b/backend/routers/vocabRouter.js index fdfb290..ac4b4e6 100644 --- a/backend/routers/vocabRouter.js +++ b/backend/routers/vocabRouter.js @@ -26,6 +26,7 @@ router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter); router.post('/courses', vocabController.createCourse); router.get('/courses', vocabController.getCourses); router.get('/courses/my', vocabController.getMyCourses); +router.post('/courses/find-by-code', vocabController.getCourseByShareCode); router.get('/courses/:courseId', vocabController.getCourse); router.put('/courses/:courseId', vocabController.updateCourse); router.delete('/courses/:courseId', vocabController.deleteCourse); diff --git a/backend/services/vocabService.js b/backend/services/vocabService.js index 2c5f7a7..1f773ec 100644 --- a/backend/services/vocabService.js +++ b/backend/services/vocabService.js @@ -559,27 +559,101 @@ export default class VocabService { return course.get({ plain: true }); } - async getCourses(hashedUserId, { includePublic = true, includeOwn = true } = {}) { + async getCourses(hashedUserId, { includePublic = true, includeOwn = true, languageId, search } = {}) { const user = await this._getUserByHashedId(hashedUserId); const where = {}; + const andConditions = []; + + // Zugriffsbedingungen if (includeOwn && includePublic) { - where[Op.or] = [ - { ownerUserId: user.id }, - { isPublic: true } - ]; + andConditions.push({ + [Op.or]: [ + { ownerUserId: user.id }, + { isPublic: true } + ] + }); } else if (includeOwn) { where.ownerUserId = user.id; } else if (includePublic) { where.isPublic = true; } + // Filter nach Sprache + if (languageId) { + where.languageId = Number(languageId); + } + + // Suche nach Titel oder Beschreibung + if (search && search.trim()) { + const searchTerm = `%${search.trim()}%`; + andConditions.push({ + [Op.or]: [ + { title: { [Op.iLike]: searchTerm } }, + { description: { [Op.iLike]: searchTerm } } + ] + }); + } + + // Kombiniere alle AND-Bedingungen + if (andConditions.length > 0) { + where[Op.and] = andConditions; + } + const courses = await VocabCourse.findAll({ where, order: [['createdAt', 'DESC']] }); - return courses.map(c => c.get({ plain: true })); + const coursesData = courses.map(c => c.get({ plain: true })); + + // Lade Sprachnamen für alle Kurse + const languageIds = [...new Set(coursesData.map(c => c.languageId))]; + if (languageIds.length > 0) { + const [languages] = await sequelize.query( + `SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`, + { + replacements: { languageIds }, + type: sequelize.QueryTypes.SELECT + } + ); + const languageMap = new Map(languages.map(l => [l.id, l.name])); + coursesData.forEach(c => { + c.languageName = languageMap.get(c.languageId) || null; + }); + } + + return coursesData; + } + + async getCourseByShareCode(hashedUserId, shareCode) { + const user = await this._getUserByHashedId(hashedUserId); + const code = typeof shareCode === 'string' ? shareCode.trim() : ''; + + if (!code || code.length < 6 || code.length > 128) { + const err = new Error('Invalid share code'); + err.status = 400; + throw err; + } + + const course = await VocabCourse.findOne({ + where: { shareCode: code } + }); + + if (!course) { + const err = new Error('Course not found'); + err.status = 404; + throw err; + } + + // Prüfe Zugriff (öffentlich oder Besitzer) + if (course.ownerUserId !== user.id && !course.isPublic) { + const err = new Error('Course is not public'); + err.status = 403; + throw err; + } + + return course.get({ plain: true }); } async getCourse(hashedUserId, courseId) { diff --git a/frontend/src/i18n/locales/de/socialnetwork.json b/frontend/src/i18n/locales/de/socialnetwork.json index 30016eb..4f77f7f 100644 --- a/frontend/src/i18n/locales/de/socialnetwork.json +++ b/frontend/src/i18n/locales/de/socialnetwork.json @@ -347,7 +347,13 @@ "confirmDelete": "Lektion wirklich löschen?", "titleLabel": "Titel", "descriptionLabel": "Beschreibung", - "languageLabel": "Sprache" + "languageLabel": "Sprache", + "findByCode": "Kurs per Code finden", + "shareCode": "Share-Code", + "searchPlaceholder": "Kurs suchen...", + "allLanguages": "Alle Sprachen", + "invalidCode": "Ungültiger Code", + "courseNotFound": "Kurs nicht gefunden" } } } diff --git a/frontend/src/i18n/locales/en/socialnetwork.json b/frontend/src/i18n/locales/en/socialnetwork.json index 5275922..228086c 100644 --- a/frontend/src/i18n/locales/en/socialnetwork.json +++ b/frontend/src/i18n/locales/en/socialnetwork.json @@ -347,7 +347,13 @@ "confirmDelete": "Really delete lesson?", "titleLabel": "Title", "descriptionLabel": "Description", - "languageLabel": "Language" + "languageLabel": "Language", + "findByCode": "Find Course by Code", + "shareCode": "Share Code", + "searchPlaceholder": "Search courses...", + "allLanguages": "All Languages", + "invalidCode": "Invalid code", + "courseNotFound": "Course not found" } } } diff --git a/frontend/src/views/social/VocabCourseListView.vue b/frontend/src/views/social/VocabCourseListView.vue index 1b22cde..adc167d 100644 --- a/frontend/src/views/social/VocabCourseListView.vue +++ b/frontend/src/views/social/VocabCourseListView.vue @@ -7,6 +7,24 @@ + + + + +
+ +
+ +
{{ $t('general.loading') }}
@@ -24,6 +42,7 @@

{{ course.description }}

+ {{ $t('socialnetwork.vocab.courses.language') }}: {{ course.languageName }} {{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }} {{ $t('socialnetwork.vocab.courses.lessons') }}: {{ course.lessons.length }}
@@ -43,6 +62,23 @@ + +
+
+

{{ $t('socialnetwork.vocab.courses.findByCode') }}

+
+
+ + +
+
+ + +
+
+
+
+
@@ -119,7 +155,17 @@ export default { async loadAllCourses() { this.loading = true; try { - const res = await apiClient.get('/api/vocab/courses', { params: { includePublic: true, includeOwn: true } }); + const params = { + includePublic: true, + includeOwn: true + }; + if (this.selectedLanguageId) { + params.languageId = this.selectedLanguageId; + } + if (this.searchTerm.trim()) { + params.search = this.searchTerm.trim(); + } + const res = await apiClient.get('/api/vocab/courses', { params }); const courses = res.data || []; // Füge isOwner Flag hinzu this.courses = courses.map(c => ({ @@ -132,6 +178,34 @@ export default { this.loading = false; } }, + debouncedSearch() { + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + this.searchTimeout = setTimeout(() => { + this.loadAllCourses(); + }, 500); + }, + async findCourseByCode() { + if (!this.shareCode.trim()) { + alert(this.$t('socialnetwork.vocab.courses.invalidCode')); + return; + } + this.loading = true; + try { + const res = await apiClient.post('/api/vocab/courses/find-by-code', { shareCode: this.shareCode }); + const course = res.data; + this.showShareCodeDialog = false; + this.shareCode = ''; + // Öffne den gefundenen Kurs + this.openCourse(course.id); + } catch (e) { + console.error('Fehler beim Suchen des Kurses:', e); + alert(e.response?.data?.error || this.$t('socialnetwork.vocab.courses.courseNotFound')); + } finally { + this.loading = false; + } + }, async loadMyCourses() { this.loading = true; try { @@ -327,4 +401,29 @@ export default { justify-content: flex-end; margin-top: 20px; } + +.search-filter { + display: flex; + gap: 15px; + margin: 15px 0; + align-items: center; +} + +.search-box { + flex: 1; +} + +.search-box input { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.filter-box select { + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + min-width: 200px; +} diff --git a/frontend/src/views/social/VocabCourseView.vue b/frontend/src/views/social/VocabCourseView.vue index 3fa23ed..5ef2bd4 100644 --- a/frontend/src/views/social/VocabCourseView.vue +++ b/frontend/src/views/social/VocabCourseView.vue @@ -8,6 +8,9 @@
{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }} {{ $t('socialnetwork.vocab.courses.public') }} +
@@ -206,6 +209,18 @@ export default { gap: 15px; margin: 15px 0; color: #666; + flex-wrap: wrap; +} + +.share-code { + font-family: monospace; +} + +.share-code code { + background: #f0f0f0; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.9em; } .owner-actions {