Add course retrieval by share code feature and enhance course search functionality
- Implemented a new endpoint in VocabController to retrieve courses using a share code. - Updated VocabService to include logic for validating share codes and checking course access permissions. - Enhanced course listing functionality with search and language filtering options in the frontend. - Added a dialog for users to input share codes and search for courses, improving user experience. - Updated internationalization files to include new strings for share code functionality and search features.
This commit is contained in:
@@ -26,6 +26,7 @@ class VocabController {
|
|||||||
this.createCourse = this._wrapWithUser((userId, req) => this.service.createCourse(userId, req.body), { successStatus: 201 });
|
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.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.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.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));
|
this.deleteCourse = this._wrapWithUser((userId, req) => this.service.deleteCourse(userId, req.params.courseId));
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
|
|||||||
router.post('/courses', vocabController.createCourse);
|
router.post('/courses', vocabController.createCourse);
|
||||||
router.get('/courses', vocabController.getCourses);
|
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.get('/courses/:courseId', vocabController.getCourse);
|
router.get('/courses/:courseId', vocabController.getCourse);
|
||||||
router.put('/courses/:courseId', vocabController.updateCourse);
|
router.put('/courses/:courseId', vocabController.updateCourse);
|
||||||
router.delete('/courses/:courseId', vocabController.deleteCourse);
|
router.delete('/courses/:courseId', vocabController.deleteCourse);
|
||||||
|
|||||||
@@ -559,27 +559,101 @@ export default class VocabService {
|
|||||||
return course.get({ plain: true });
|
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 user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
|
||||||
const where = {};
|
const where = {};
|
||||||
|
const andConditions = [];
|
||||||
|
|
||||||
|
// Zugriffsbedingungen
|
||||||
if (includeOwn && includePublic) {
|
if (includeOwn && includePublic) {
|
||||||
where[Op.or] = [
|
andConditions.push({
|
||||||
|
[Op.or]: [
|
||||||
{ ownerUserId: user.id },
|
{ ownerUserId: user.id },
|
||||||
{ isPublic: true }
|
{ isPublic: true }
|
||||||
];
|
]
|
||||||
|
});
|
||||||
} else if (includeOwn) {
|
} else if (includeOwn) {
|
||||||
where.ownerUserId = user.id;
|
where.ownerUserId = user.id;
|
||||||
} else if (includePublic) {
|
} else if (includePublic) {
|
||||||
where.isPublic = true;
|
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({
|
const courses = await VocabCourse.findAll({
|
||||||
where,
|
where,
|
||||||
order: [['createdAt', 'DESC']]
|
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) {
|
async getCourse(hashedUserId, courseId) {
|
||||||
|
|||||||
@@ -347,7 +347,13 @@
|
|||||||
"confirmDelete": "Lektion wirklich löschen?",
|
"confirmDelete": "Lektion wirklich löschen?",
|
||||||
"titleLabel": "Titel",
|
"titleLabel": "Titel",
|
||||||
"descriptionLabel": "Beschreibung",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,7 +347,13 @@
|
|||||||
"confirmDelete": "Really delete lesson?",
|
"confirmDelete": "Really delete lesson?",
|
||||||
"titleLabel": "Title",
|
"titleLabel": "Title",
|
||||||
"descriptionLabel": "Description",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,24 @@
|
|||||||
<button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button>
|
<button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button>
|
||||||
<button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button>
|
<button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button>
|
||||||
<button @click="loadAllCourses">{{ $t('socialnetwork.vocab.courses.allCourses') }}</button>
|
<button @click="loadAllCourses">{{ $t('socialnetwork.vocab.courses.allCourses') }}</button>
|
||||||
|
<button @click="showShareCodeDialog = true">{{ $t('socialnetwork.vocab.courses.findByCode') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Such- und Filter-Bereich -->
|
||||||
|
<div class="search-filter">
|
||||||
|
<div class="search-box">
|
||||||
|
<input
|
||||||
|
v-model="searchTerm"
|
||||||
|
:placeholder="$t('socialnetwork.vocab.courses.searchPlaceholder')"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-box">
|
||||||
|
<select v-model="selectedLanguageId" @change="loadAllCourses">
|
||||||
|
<option value="">{{ $t('socialnetwork.vocab.courses.allLanguages') }}</option>
|
||||||
|
<option v-for="lang in languages" :key="lang.id" :value="lang.id">{{ lang.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||||
@@ -24,6 +42,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="course.description" class="course-description">{{ course.description }}</p>
|
<p v-if="course.description" class="course-description">{{ course.description }}</p>
|
||||||
<div class="course-meta">
|
<div class="course-meta">
|
||||||
|
<span v-if="course.languageName">{{ $t('socialnetwork.vocab.courses.language') }}: {{ course.languageName }}</span>
|
||||||
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
||||||
<span v-if="course.lessons">{{ $t('socialnetwork.vocab.courses.lessons') }}: {{ course.lessons.length }}</span>
|
<span v-if="course.lessons">{{ $t('socialnetwork.vocab.courses.lessons') }}: {{ course.lessons.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +62,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Code Dialog -->
|
||||||
|
<div v-if="showShareCodeDialog" class="dialog-overlay" @click="showShareCodeDialog = false">
|
||||||
|
<div class="dialog" @click.stop>
|
||||||
|
<h3>{{ $t('socialnetwork.vocab.courses.findByCode') }}</h3>
|
||||||
|
<form @submit.prevent="findCourseByCode">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ $t('socialnetwork.vocab.courses.shareCode') }}</label>
|
||||||
|
<input v-model="shareCode" placeholder="z.B. abc123def456" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">{{ $t('general.search') }}</button>
|
||||||
|
<button type="button" @click="showShareCodeDialog = false">{{ $t('general.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create Course Dialog -->
|
<!-- Create Course Dialog -->
|
||||||
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
<div v-if="showCreateDialog" class="dialog-overlay" @click="showCreateDialog = false">
|
||||||
<div class="dialog" @click.stop>
|
<div class="dialog" @click.stop>
|
||||||
@@ -119,7 +155,17 @@ export default {
|
|||||||
async loadAllCourses() {
|
async loadAllCourses() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
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 || [];
|
const courses = res.data || [];
|
||||||
// Füge isOwner Flag hinzu
|
// Füge isOwner Flag hinzu
|
||||||
this.courses = courses.map(c => ({
|
this.courses = courses.map(c => ({
|
||||||
@@ -132,6 +178,34 @@ export default {
|
|||||||
this.loading = false;
|
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() {
|
async loadMyCourses() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
@@ -327,4 +401,29 @@ export default {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 20px;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="course-info">
|
<div class="course-info">
|
||||||
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
|
||||||
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
|
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
|
||||||
|
<span v-if="course.shareCode && isOwner" class="share-code">
|
||||||
|
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isOwner" class="owner-actions">
|
<div v-if="isOwner" class="owner-actions">
|
||||||
@@ -206,6 +209,18 @@ export default {
|
|||||||
gap: 15px;
|
gap: 15px;
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
color: #666;
|
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 {
|
.owner-actions {
|
||||||
|
|||||||
Reference in New Issue
Block a user