feat(vocab): implement user vocab lesson progress reset functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
- Added `resetUserVocabLessonProgress` method in `AdminController` to allow admins to reset a user's progress for a specific vocab lesson. - Introduced corresponding route in `adminRouter` for the new reset functionality. - Enhanced `VocabService` with methods to purge lesson progress for users, ensuring that only the specified lesson's progress is affected. - Updated UI components in `UsersView` to facilitate the selection of courses and lessons for resetting progress, including confirmation dialogs and loading states. - Added localization support for the new reset functionality across multiple languages. - Implemented reset functionality in `VocabLessonView` for users to reset their own lesson progress.
This commit is contained in:
@@ -33,6 +33,7 @@ class AdminController {
|
|||||||
this.getUser = this.getUser.bind(this);
|
this.getUser = this.getUser.bind(this);
|
||||||
this.getUsers = this.getUsers.bind(this);
|
this.getUsers = this.getUsers.bind(this);
|
||||||
this.updateUser = this.updateUser.bind(this);
|
this.updateUser = this.updateUser.bind(this);
|
||||||
|
this.resetUserVocabLessonProgress = this.resetUserVocabLessonProgress.bind(this);
|
||||||
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
|
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
|
||||||
this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this);
|
this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this);
|
||||||
this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this);
|
this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this);
|
||||||
@@ -129,6 +130,25 @@ class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async resetUserVocabLessonProgress(req, res) {
|
||||||
|
const schema = Joi.object({
|
||||||
|
lessonId: Joi.number().integer().positive().required()
|
||||||
|
});
|
||||||
|
const { error, value } = schema.validate(req.body || {});
|
||||||
|
if (error) {
|
||||||
|
return res.status(400).json({ error: error.details[0].message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await AdminService.adminResetUserVocabLessonProgress(requester, id, value.lessonId);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message === 'noaccess' ? 403 : (err.message === 'lessonnotfound' ? 404 : 500);
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAdultVerificationRequests(req, res) {
|
async getAdultVerificationRequests(req, res) {
|
||||||
try {
|
try {
|
||||||
const { userid: requester } = req.headers;
|
const { userid: requester } = req.headers;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class VocabController {
|
|||||||
// Progress
|
// Progress
|
||||||
this.getCourseProgress = this._wrapWithUser((userId, req) => this.service.getCourseProgress(userId, req.params.courseId));
|
this.getCourseProgress = this._wrapWithUser((userId, req) => this.service.getCourseProgress(userId, req.params.courseId));
|
||||||
this.updateLessonProgress = this._wrapWithUser((userId, req) => this.service.updateLessonProgress(userId, req.params.lessonId, req.body));
|
this.updateLessonProgress = this._wrapWithUser((userId, req) => this.service.updateLessonProgress(userId, req.params.lessonId, req.body));
|
||||||
|
this.resetLessonProgress = this._wrapWithUser((userId, req) => this.service.resetMyLessonProgress(userId, req.params.lessonId));
|
||||||
|
|
||||||
// Grammar Exercises
|
// Grammar Exercises
|
||||||
this.getExerciseTypes = this._wrapWithUser((userId) => this.service.getExerciseTypes());
|
this.getExerciseTypes = this._wrapWithUser((userId) => this.service.getExerciseTypes());
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ router.put('/users/:id/adult-verification', authenticate, adminController.setAdu
|
|||||||
router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports);
|
router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports);
|
||||||
router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview);
|
router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview);
|
||||||
router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction);
|
router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction);
|
||||||
|
router.post('/users/:id/vocab-lesson-progress/reset', authenticate, adminController.resetUserVocabLessonProgress);
|
||||||
router.get('/users/:id', authenticate, adminController.getUser);
|
router.get('/users/:id', authenticate, adminController.getUser);
|
||||||
router.put('/users/:id', authenticate, adminController.updateUser);
|
router.put('/users/:id', authenticate, adminController.updateUser);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ router.delete('/courses/:courseId/enroll', vocabController.unenrollFromCourse);
|
|||||||
router.get('/courses/:courseId/progress', vocabController.getCourseProgress);
|
router.get('/courses/:courseId/progress', vocabController.getCourseProgress);
|
||||||
router.get('/lessons/:lessonId', vocabController.getLesson);
|
router.get('/lessons/:lessonId', vocabController.getLesson);
|
||||||
router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
|
router.put('/lessons/:lessonId/progress', vocabController.updateLessonProgress);
|
||||||
|
router.delete('/lessons/:lessonId/progress', vocabController.resetLessonProgress);
|
||||||
|
|
||||||
// Grammar Exercises
|
// Grammar Exercises
|
||||||
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
|
router.get('/grammar/exercise-types', vocabController.getExerciseTypes);
|
||||||
|
|||||||
@@ -69,17 +69,19 @@ const LESSON_DIDACTICS = {
|
|||||||
{ target: 'Si Tatay.', gloss: 'Das ist Papa.' },
|
{ target: 'Si Tatay.', gloss: 'Das ist Papa.' },
|
||||||
{ target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' },
|
{ target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' },
|
||||||
{ target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' },
|
{ target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' },
|
||||||
|
{ target: 'Si Dodong nako.', gloss: 'Das ist mein jüngerer Bruder.' },
|
||||||
|
{ target: 'Si Inday nako.', gloss: 'Das ist meine jüngere Schwester.' },
|
||||||
{ target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' },
|
{ target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' },
|
||||||
{ target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' }
|
{ target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' }
|
||||||
],
|
],
|
||||||
grammarFocus: [
|
grammarFocus: [
|
||||||
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' },
|
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate richtest du an ältere Geschwister (oder respektvoll an andere). Dodong und Inday nutzt du für jüngere Brüder bzw. Schwestern; „Ading“ ist eine weiche Anrede an jüngere Geschwister.', example: 'Kuya, palihug. / Si Dodong nako.' },
|
||||||
{ title: 'si als Personenmarker', text: 'Mit "si" markierst du im einfachen Satz eine konkrete Person.', example: 'Si Nanay. Si Tatay.' }
|
{ title: 'si als Personenmarker', text: 'Mit "si" markierst du im einfachen Satz eine konkrete Person.', example: 'Si Nanay. Si Tatay.' }
|
||||||
],
|
],
|
||||||
speakingPrompts: [
|
speakingPrompts: [
|
||||||
{ title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Lola nako. Si Kuya nako.' }
|
{ title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Kuya nako. Si Dodong nako.' }
|
||||||
],
|
],
|
||||||
practicalTasks: [{ title: 'Familienpraxis', text: 'Nenne laut sechs Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' }]
|
practicalTasks: [{ title: 'Familienpraxis', text: 'Nenne laut die acht Kern-Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' }]
|
||||||
},
|
},
|
||||||
'Überlebenssätze - Teil 1': {
|
'Überlebenssätze - Teil 1': {
|
||||||
learningGoals: [
|
learningGoals: [
|
||||||
@@ -116,6 +118,8 @@ const LESSON_DIDACTICS = {
|
|||||||
{ target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' },
|
{ target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' },
|
||||||
{ target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' },
|
{ target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' },
|
||||||
{ target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' },
|
{ target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' },
|
||||||
|
{ target: 'Kumusta na ang Dodong?', gloss: 'Wie geht es dem jüngeren Bruder?' },
|
||||||
|
{ target: 'Kumusta na ang Inday?', gloss: 'Wie geht es der jüngeren Schwester?' },
|
||||||
{ target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' },
|
{ target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' },
|
||||||
{ target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' }
|
{ target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -770,6 +770,36 @@ const BISAYA_EXERCISES = {
|
|||||||
},
|
},
|
||||||
explanation: '"Ate" bedeutet "ältere Schwester" auf Bisaya. Wird auch für respektvolle Anrede von älteren Frauen verwendet.'
|
explanation: '"Ate" bedeutet "ältere Schwester" auf Bisaya. Wird auch für respektvolle Anrede von älteren Frauen verwendet.'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "jüngerer Bruder" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "jüngerer Bruder" auf Bisaya?',
|
||||||
|
options: ['Dodong', 'Inday', 'Kuya', 'Ate']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Dodong" ist die gängige Bisaya-Bezeichnung für einen jüngeren Bruder (von älteren Geschwistern aus gesehen).'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
exerciseTypeId: 2, // multiple_choice
|
||||||
|
title: 'Wie sagt man "jüngere Schwester" auf Bisaya?',
|
||||||
|
instruction: 'Wähle die richtige Übersetzung.',
|
||||||
|
questionData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
question: 'Wie sagt man "jüngere Schwester" auf Bisaya?',
|
||||||
|
options: ['Inday', 'Dodong', 'Ate', 'Kuya']
|
||||||
|
},
|
||||||
|
answerData: {
|
||||||
|
type: 'multiple_choice',
|
||||||
|
correctAnswer: 0
|
||||||
|
},
|
||||||
|
explanation: '"Inday" ist die gängige Bisaya-Bezeichnung für eine jüngere Schwester (von älteren Geschwistern aus gesehen).'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
exerciseTypeId: 2, // multiple_choice
|
exerciseTypeId: 2, // multiple_choice
|
||||||
title: 'Wie sagt man "Großmutter" auf Bisaya?',
|
title: 'Wie sagt man "Großmutter" auf Bisaya?',
|
||||||
@@ -806,14 +836,14 @@ const BISAYA_EXERCISES = {
|
|||||||
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Familienwörtern.',
|
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Familienwörtern.',
|
||||||
questionData: {
|
questionData: {
|
||||||
type: 'gap_fill',
|
type: 'gap_fill',
|
||||||
text: '{gap} (Mutter) | {gap} (Vater) | {gap} (älterer Bruder) | {gap} (ältere Schwester) | {gap} (Großmutter) | {gap} (Großvater)',
|
text: '{gap} (Mutter) | {gap} (Vater) | {gap} (älterer Bruder) | {gap} (ältere Schwester) | {gap} (jüngerer Bruder) | {gap} (jüngere Schwester) | {gap} (Großmutter) | {gap} (Großvater)',
|
||||||
gaps: 6
|
gaps: 8
|
||||||
},
|
},
|
||||||
answerData: {
|
answerData: {
|
||||||
type: 'gap_fill',
|
type: 'gap_fill',
|
||||||
answers: ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Lola', 'Lolo']
|
answers: ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Dodong', 'Inday', 'Lola', 'Lolo']
|
||||||
},
|
},
|
||||||
explanation: 'Nanay = Mutter, Tatay = Vater, Kuya = älterer Bruder, Ate = ältere Schwester, Lola = Großmutter, Lolo = Großvater.'
|
explanation: 'Nanay = Mutter, Tatay = Vater, Kuya = älterer Bruder, Ate = ältere Schwester, Dodong = jüngerer Bruder, Inday = jüngere Schwester, Lola = Großmutter, Lolo = Großvater.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
exerciseTypeId: 4, // transformation
|
exerciseTypeId: 4, // transformation
|
||||||
|
|||||||
@@ -117,14 +117,16 @@ const LESSON_DIDACTICS = {
|
|||||||
{ target: 'Si Tatay.', gloss: 'Das ist Papa.' },
|
{ target: 'Si Tatay.', gloss: 'Das ist Papa.' },
|
||||||
{ target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' },
|
{ target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' },
|
||||||
{ target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' },
|
{ target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' },
|
||||||
|
{ target: 'Si Dodong nako.', gloss: 'Das ist mein jüngerer Bruder.' },
|
||||||
|
{ target: 'Si Inday nako.', gloss: 'Das ist meine jüngere Schwester.' },
|
||||||
{ target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' },
|
{ target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' },
|
||||||
{ target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' }
|
{ target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' }
|
||||||
],
|
],
|
||||||
grammarFocus: [
|
grammarFocus: [
|
||||||
{
|
{
|
||||||
title: 'Respekt in Familienanreden',
|
title: 'Respekt in Familienanreden',
|
||||||
text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.',
|
text: 'Kuya und Ate richtest du an ältere Geschwister (oder respektvoll an andere). Dodong und Inday nutzt du für jüngere Brüder bzw. Schwestern; „Ading“ ist eine weiche Anrede an jüngere Geschwister.',
|
||||||
example: 'Kuya, palihug.'
|
example: 'Kuya, palihug. / Si Dodong nako.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'si als Personenmarker',
|
title: 'si als Personenmarker',
|
||||||
@@ -136,13 +138,13 @@ const LESSON_DIDACTICS = {
|
|||||||
{
|
{
|
||||||
title: 'Meine Familie',
|
title: 'Meine Familie',
|
||||||
prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.',
|
prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.',
|
||||||
cue: 'Si Nanay. Si Tatay. Si Lola nako. Si Kuya nako.'
|
cue: 'Si Nanay. Si Tatay. Si Kuya nako. Si Dodong nako.'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
practicalTasks: [
|
practicalTasks: [
|
||||||
{
|
{
|
||||||
title: 'Familienpraxis',
|
title: 'Familienpraxis',
|
||||||
text: 'Nenne laut sechs Familienwörter und bilde danach drei Mini-Sätze über deine Familie.'
|
text: 'Nenne laut die acht Kern-Familienwörter und bilde danach drei Mini-Sätze über deine Familie.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -202,6 +204,8 @@ const LESSON_DIDACTICS = {
|
|||||||
{ target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' },
|
{ target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' },
|
||||||
{ target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' },
|
{ target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' },
|
||||||
{ target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' },
|
{ target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' },
|
||||||
|
{ target: 'Kumusta na ang Dodong?', gloss: 'Wie geht es dem jüngeren Bruder?' },
|
||||||
|
{ target: 'Kumusta na ang Inday?', gloss: 'Wie geht es der jüngeren Schwester?' },
|
||||||
{ target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' },
|
{ target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' },
|
||||||
{ target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' }
|
{ target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' }
|
||||||
],
|
],
|
||||||
@@ -463,9 +467,9 @@ const LESSONS = [
|
|||||||
cultural: 'Diese Sätze helfen dir sofort im Alltag weiter.' },
|
cultural: 'Diese Sätze helfen dir sofort im Alltag weiter.' },
|
||||||
|
|
||||||
{ week: 1, day: 2, num: 3, type: 'vocab', title: 'Familienwörter',
|
{ week: 1, day: 2, num: 3, type: 'vocab', title: 'Familienwörter',
|
||||||
desc: 'Mama, Papa, Kuya, Ate, Lola, Lolo und mehr',
|
desc: 'Mama, Papa, Kuya, Ate, Dodong, Inday, Lola, Lolo',
|
||||||
targetMin: 20, targetScore: 85, review: true,
|
targetMin: 20, targetScore: 85, review: true,
|
||||||
cultural: 'Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll!' },
|
cultural: 'Kuya und Ate für Ältere; Dodong und Inday für jüngere Geschwister. Kuya/Ate werden auch respektvoll außerhalb der Familie genutzt.' },
|
||||||
|
|
||||||
{ week: 1, day: 2, num: 4, type: 'conversation', title: 'Familien-Gespräche',
|
{ week: 1, day: 2, num: 4, type: 'conversation', title: 'Familien-Gespräche',
|
||||||
desc: 'Einfache Gespräche mit Familienmitgliedern',
|
desc: 'Einfache Gespräche mit Familienmitgliedern',
|
||||||
|
|||||||
@@ -67,18 +67,20 @@ const LESSON_DIDACTICS = {
|
|||||||
{ target: 'Si Tatay.', gloss: 'Das ist Papa.' },
|
{ target: 'Si Tatay.', gloss: 'Das ist Papa.' },
|
||||||
{ target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' },
|
{ target: 'Si Kuya nako.', gloss: 'Das ist mein älterer Bruder.' },
|
||||||
{ target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' },
|
{ target: 'Si Ate nako.', gloss: 'Das ist meine ältere Schwester.' },
|
||||||
|
{ target: 'Si Dodong nako.', gloss: 'Das ist mein jüngerer Bruder.' },
|
||||||
|
{ target: 'Si Inday nako.', gloss: 'Das ist meine jüngere Schwester.' },
|
||||||
{ target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' },
|
{ target: 'Si Lola nako.', gloss: 'Das ist meine Großmutter.' },
|
||||||
{ target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' }
|
{ target: 'Si Lolo nako.', gloss: 'Das ist mein Großvater.' }
|
||||||
],
|
],
|
||||||
grammarFocus: [
|
grammarFocus: [
|
||||||
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate werden nicht nur in der Familie, sondern auch respektvoll für ältere Personen benutzt.', example: 'Kuya, palihug.' },
|
{ title: 'Respekt in Familienanreden', text: 'Kuya und Ate richtest du an ältere Geschwister (oder respektvoll an andere). Dodong und Inday nutzt du für jüngere Brüder bzw. Schwestern; „Ading“ ist eine weiche Anrede an jüngere Geschwister.', example: 'Kuya, palihug. / Si Dodong nako.' },
|
||||||
{ title: 'si als Personenmarker', text: 'Mit "si" markierst du im einfachen Satz eine konkrete Person.', example: 'Si Nanay. Si Tatay.' }
|
{ title: 'si als Personenmarker', text: 'Mit "si" markierst du im einfachen Satz eine konkrete Person.', example: 'Si Nanay. Si Tatay.' }
|
||||||
],
|
],
|
||||||
speakingPrompts: [
|
speakingPrompts: [
|
||||||
{ title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Lola nako. Si Kuya nako.' }
|
{ title: 'Meine Familie', prompt: 'Stelle vier Familienmitglieder mit kurzen Sätzen vor.', cue: 'Si Nanay. Si Tatay. Si Kuya nako. Si Dodong nako.' }
|
||||||
],
|
],
|
||||||
practicalTasks: [
|
practicalTasks: [
|
||||||
{ title: 'Familienpraxis', text: 'Nenne laut sechs Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' }
|
{ title: 'Familienpraxis', text: 'Nenne laut die acht Kern-Familienwörter und bilde danach drei Mini-Sätze über deine Familie.' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'Überlebenssätze - Teil 1': {
|
'Überlebenssätze - Teil 1': {
|
||||||
@@ -118,6 +120,8 @@ const LESSON_DIDACTICS = {
|
|||||||
{ target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' },
|
{ target: 'Asa si Tatay?', gloss: 'Wo ist Papa?' },
|
||||||
{ target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' },
|
{ target: 'Naa siya sa balay.', gloss: 'Er ist zu Hause.' },
|
||||||
{ target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' },
|
{ target: 'Kumusta na ang Kuya?', gloss: 'Wie geht es dem älteren Bruder?' },
|
||||||
|
{ target: 'Kumusta na ang Dodong?', gloss: 'Wie geht es dem jüngeren Bruder?' },
|
||||||
|
{ target: 'Kumusta na ang Inday?', gloss: 'Wie geht es der jüngeren Schwester?' },
|
||||||
{ target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' },
|
{ target: 'Gutom na ko, Nanay.', gloss: 'Ich habe Hunger, Mama.' },
|
||||||
{ target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' }
|
{ target: 'Hapit na ang pagkaon.', gloss: 'Das Essen ist fast fertig.' }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ const FAMILY_WORDS = {
|
|||||||
it: 'sorella maggiore',
|
it: 'sorella maggiore',
|
||||||
pt: 'irmã mais velha'
|
pt: 'irmã mais velha'
|
||||||
},
|
},
|
||||||
|
'jüngerer Bruder': {
|
||||||
|
de: 'jüngerer Bruder',
|
||||||
|
en: 'younger brother',
|
||||||
|
es: 'hermano menor',
|
||||||
|
fr: 'frère cadet',
|
||||||
|
it: 'fratello minore',
|
||||||
|
pt: 'irmão mais novo'
|
||||||
|
},
|
||||||
|
'jüngere Schwester': {
|
||||||
|
de: 'jüngere Schwester',
|
||||||
|
en: 'younger sister',
|
||||||
|
es: 'hermana menor',
|
||||||
|
fr: 'sœur cadette',
|
||||||
|
it: 'sorella minore',
|
||||||
|
pt: 'irmã mais nova'
|
||||||
|
},
|
||||||
Großmutter: {
|
Großmutter: {
|
||||||
de: 'Großmutter',
|
de: 'Großmutter',
|
||||||
en: 'Grandmother',
|
en: 'Grandmother',
|
||||||
@@ -72,6 +88,8 @@ const BISAYA_TRANSLATIONS = {
|
|||||||
'Vater': 'Tatay',
|
'Vater': 'Tatay',
|
||||||
'älterer Bruder': 'Kuya',
|
'älterer Bruder': 'Kuya',
|
||||||
'ältere Schwester': 'Ate',
|
'ältere Schwester': 'Ate',
|
||||||
|
'jüngerer Bruder': 'Dodong',
|
||||||
|
'jüngere Schwester': 'Inday',
|
||||||
'Großmutter': 'Lola',
|
'Großmutter': 'Lola',
|
||||||
'Großvater': 'Lolo'
|
'Großvater': 'Lolo'
|
||||||
};
|
};
|
||||||
@@ -98,7 +116,7 @@ function createFamilyWordsExercises(nativeLanguageName) {
|
|||||||
|
|
||||||
// Multiple Choice Übungen für jedes Familienwort
|
// Multiple Choice Übungen für jedes Familienwort
|
||||||
const familyWords = Object.keys(FAMILY_WORDS);
|
const familyWords = Object.keys(FAMILY_WORDS);
|
||||||
const bisayaWords = ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Lola', 'Lolo'];
|
const bisayaWords = ['Nanay', 'Tatay', 'Kuya', 'Ate', 'Dodong', 'Inday', 'Lola', 'Lolo'];
|
||||||
|
|
||||||
familyWords.forEach((key, index) => {
|
familyWords.forEach((key, index) => {
|
||||||
const nativeWord = FAMILY_WORDS[key][langCode];
|
const nativeWord = FAMILY_WORDS[key][langCode];
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import RelationshipType from "../models/falukant/type/relationship.js";
|
|||||||
import RelationshipState from "../models/falukant/data/relationship_state.js";
|
import RelationshipState from "../models/falukant/data/relationship_state.js";
|
||||||
import { sequelize } from '../utils/sequelize.js';
|
import { sequelize } from '../utils/sequelize.js';
|
||||||
import npcCreationJobService from './npcCreationJobService.js';
|
import npcCreationJobService from './npcCreationJobService.js';
|
||||||
|
import VocabService from './vocabService.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -1957,6 +1958,21 @@ class AdminService {
|
|||||||
}
|
}
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminResetUserVocabLessonProgress(requesterHashedId, targetHashedId, lessonId) {
|
||||||
|
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
const vocab = new VocabService();
|
||||||
|
try {
|
||||||
|
return await vocab.adminResetLessonProgressForUser(targetHashedId, Number(lessonId));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
throw new Error('lessonnotfound');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AdminService();
|
export default new AdminService();
|
||||||
|
|||||||
@@ -2485,6 +2485,66 @@ export default class VocabService {
|
|||||||
return this._serializeLessonProgress(progress, lessonData);
|
return this._serializeLessonProgress(progress, lessonData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht nur den Fortschritt zu einer Lektion (Zeile vocab_course_progress + zugehörige grammar-exercise-progress).
|
||||||
|
* Gesamtkurs / andere Lektionen bleiben unberührt.
|
||||||
|
*/
|
||||||
|
async _purgeLessonProgressForUser(userId, lessonId) {
|
||||||
|
const numericLessonId = Number(lessonId);
|
||||||
|
const exercises = await VocabGrammarExercise.findAll({
|
||||||
|
where: { lessonId: numericLessonId },
|
||||||
|
attributes: ['id']
|
||||||
|
});
|
||||||
|
const exerciseIds = exercises.map((e) => e.id);
|
||||||
|
let deletedExerciseProgressRows = 0;
|
||||||
|
if (exerciseIds.length > 0) {
|
||||||
|
deletedExerciseProgressRows = await VocabGrammarExerciseProgress.destroy({
|
||||||
|
where: { userId, exerciseId: { [Op.in]: exerciseIds } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const deletedLessonProgressRows = await VocabCourseProgress.destroy({
|
||||||
|
where: { userId, lessonId: numericLessonId }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
lessonId: numericLessonId,
|
||||||
|
deletedLessonProgressRows,
|
||||||
|
deletedExerciseProgressRows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eingeloggter Nutzer setzt eigene Lektion zurück (nur bei Kurs-Einschreibung). */
|
||||||
|
async resetMyLessonProgress(hashedUserId, lessonId) {
|
||||||
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
|
||||||
|
if (!lesson) {
|
||||||
|
const err = new Error('Lesson not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const enrollment = await VocabCourseEnrollment.findOne({
|
||||||
|
where: { userId: user.id, courseId: lesson.courseId }
|
||||||
|
});
|
||||||
|
if (!enrollment) {
|
||||||
|
const err = new Error('Not enrolled in this course');
|
||||||
|
err.status = 403;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this._purgeLessonProgressForUser(user.id, lesson.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin: Zielnutzer per Hash, ohne Einschreibungszwang (idempotentes Löschen). */
|
||||||
|
async adminResetLessonProgressForUser(targetHashedUserId, lessonId) {
|
||||||
|
const user = await this._getUserByHashedId(targetHashedUserId);
|
||||||
|
const lesson = await VocabCourseLesson.findByPk(Number(lessonId));
|
||||||
|
if (!lesson) {
|
||||||
|
const err = new Error('Lesson not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return this._purgeLessonProgressForUser(user.id, lesson.id);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== GRAMMAR EXERCISE METHODS ==========
|
// ========== GRAMMAR EXERCISE METHODS ==========
|
||||||
|
|
||||||
async getExerciseTypes() {
|
async getExerciseTypes() {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ Dieses Dokument zeigt, wie du einen strukturierten Sprachkurs wie den beschriebe
|
|||||||
- Papa / Tatay – Vater
|
- Papa / Tatay – Vater
|
||||||
- Kuya – älterer Bruder
|
- Kuya – älterer Bruder
|
||||||
- Ate – ältere Schwester
|
- Ate – ältere Schwester
|
||||||
|
- Dodong – jüngerer Bruder
|
||||||
|
- Inday – jüngere Schwester
|
||||||
- Lola / Lolo – Oma / Opa
|
- Lola / Lolo – Oma / Opa
|
||||||
- **Kulturelle Notizen:** Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll!
|
- **Kulturelle Notizen:** Kuya und Ate werden auch für Nicht-Verwandte verwendet – sehr respektvoll!
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,20 @@
|
|||||||
"actions": "Mga aksyon",
|
"actions": "Mga aksyon",
|
||||||
"search": "Pangita"
|
"search": "Pangita"
|
||||||
},
|
},
|
||||||
|
"vocabLessonReset": {
|
||||||
|
"title": "Kurso sa pinulongan: pag-uswag sa leksiyon",
|
||||||
|
"intro": "Tangtanga ang pag-uswag, mga resulta sa ehersisyo ug natipig nga kahimtang sa usa ka leksiyon lamang (dili ang tibuok kurso). Makita ra ang mga kurso nga makita sa imong admin account (publiko o imoha).",
|
||||||
|
"loadCourses": "Ikarga ang mga kurso",
|
||||||
|
"selectCourse": "Kurso",
|
||||||
|
"selectLesson": "Leksiyon",
|
||||||
|
"reset": "I-reset ang leksiyon niining user",
|
||||||
|
"confirm": "Tinuod nga tangtangon ang pag-uswag sa leksiyon nga «{lesson}» ni {username}?",
|
||||||
|
"success": "Na-reset na ang pag-uswag sa leksiyon.",
|
||||||
|
"error": "Dili ma-reset.",
|
||||||
|
"pickUserFirst": "Una pagpili ug user.",
|
||||||
|
"noCourses": "Walay nakarga nga kurso o walay makita nga kurso.",
|
||||||
|
"loadingLessons": "Nagkarga sa mga leksiyon …"
|
||||||
|
},
|
||||||
"rights": {
|
"rights": {
|
||||||
"add": "Idugang ang katungod",
|
"add": "Idugang ang katungod",
|
||||||
"select": "Palihog pagpili",
|
"select": "Palihog pagpili",
|
||||||
|
|||||||
@@ -381,7 +381,11 @@
|
|||||||
"lessonReviewHintNextDue": "Sunod nga petsa: {due}.",
|
"lessonReviewHintNextDue": "Sunod nga petsa: {due}.",
|
||||||
"reviewTimeNow": "karon",
|
"reviewTimeNow": "karon",
|
||||||
"reviewTimeTomorrow": "ugma",
|
"reviewTimeTomorrow": "ugma",
|
||||||
"reviewTimeInDays": "sulod sa {count} ka adlaw"
|
"reviewTimeInDays": "sulod sa {count} ka adlaw",
|
||||||
|
"resetLessonProgress": "I-reset ang leksiyon",
|
||||||
|
"resetLessonProgressConfirm": "I-reset ang pag-uswag niining leksiyona? Mawala ang natipig nga kahimtang, mga resulta sa ehersisyo ug sa trainer. Ang ubang leksiyon dili maapektuhan.",
|
||||||
|
"resetLessonProgressSuccess": "Na-reset na ang pag-uswag sa leksiyon.",
|
||||||
|
"resetLessonProgressError": "Dili ma-reset ang leksiyon."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,20 @@
|
|||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"search": "Suchen"
|
"search": "Suchen"
|
||||||
},
|
},
|
||||||
|
"vocabLessonReset": {
|
||||||
|
"title": "Sprachkurs: Lektionsfortschritt",
|
||||||
|
"intro": "Fortschritt, Übungsergebnisse und gespeicherter Lektionszustand für eine einzelne Lektion löschen (nicht der ganze Kurs). Es werden nur Kurse gelistet, die du als Admin sehen kannst (öffentlich oder eigene).",
|
||||||
|
"loadCourses": "Kurse laden",
|
||||||
|
"selectCourse": "Kurs",
|
||||||
|
"selectLesson": "Lektion",
|
||||||
|
"reset": "Lektion für diesen Nutzer zurücksetzen",
|
||||||
|
"confirm": "Fortschritt der Lektion „{lesson}“ für {username} wirklich löschen?",
|
||||||
|
"success": "Lektionsfortschritt wurde zurückgesetzt.",
|
||||||
|
"error": "Zurücksetzen fehlgeschlagen.",
|
||||||
|
"pickUserFirst": "Zuerst einen Benutzer auswählen.",
|
||||||
|
"noCourses": "Keine Kurse geladen oder keine sichtbaren Kurse.",
|
||||||
|
"loadingLessons": "Lektionen werden geladen …"
|
||||||
|
},
|
||||||
"adultVerification": {
|
"adultVerification": {
|
||||||
"title": "[Admin] - Erotik-Freigaben",
|
"title": "[Admin] - Erotik-Freigaben",
|
||||||
"intro": "Volljährige Nutzer können den Erotikbereich beantragen. Hier werden Anfragen geprüft und freigegeben oder abgelehnt.",
|
"intro": "Volljährige Nutzer können den Erotikbereich beantragen. Hier werden Anfragen geprüft und freigegeben oder abgelehnt.",
|
||||||
|
|||||||
@@ -487,6 +487,10 @@
|
|||||||
"score": "Punktzahl",
|
"score": "Punktzahl",
|
||||||
"review": "Wiederholen",
|
"review": "Wiederholen",
|
||||||
"start": "Starten",
|
"start": "Starten",
|
||||||
|
"resetLessonProgress": "Lektion zurücksetzen",
|
||||||
|
"resetLessonProgressConfirm": "Fortschritt dieser Lektion zurücksetzen? Gespeicherter Stand, Übungsergebnisse und Trainer-Zustand werden gelöscht. Andere Lektionen bleiben unverändert.",
|
||||||
|
"resetLessonProgressSuccess": "Die Lektion wurde zurückgesetzt.",
|
||||||
|
"resetLessonProgressError": "Die Lektion konnte nicht zurückgesetzt werden.",
|
||||||
"noLessons": "Dieser Kurs hat noch keine Lektionen.",
|
"noLessons": "Dieser Kurs hat noch keine Lektionen.",
|
||||||
"lessonNumber": "Lektionsnummer",
|
"lessonNumber": "Lektionsnummer",
|
||||||
"chapter": "Kapitel",
|
"chapter": "Kapitel",
|
||||||
|
|||||||
@@ -30,6 +30,20 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"search": "Search"
|
"search": "Search"
|
||||||
},
|
},
|
||||||
|
"vocabLessonReset": {
|
||||||
|
"title": "Language course: lesson progress",
|
||||||
|
"intro": "Delete progress, exercise results and saved lesson state for a single lesson (not the whole course). Only courses you can see as this admin account are listed (public or your own).",
|
||||||
|
"loadCourses": "Load courses",
|
||||||
|
"selectCourse": "Course",
|
||||||
|
"selectLesson": "Lesson",
|
||||||
|
"reset": "Reset lesson for this user",
|
||||||
|
"confirm": "Really delete progress for lesson “{lesson}” for {username}?",
|
||||||
|
"success": "Lesson progress was reset.",
|
||||||
|
"error": "Reset failed.",
|
||||||
|
"pickUserFirst": "Select a user first.",
|
||||||
|
"noCourses": "No courses loaded or no visible courses.",
|
||||||
|
"loadingLessons": "Loading lessons…"
|
||||||
|
},
|
||||||
"adultVerification": {
|
"adultVerification": {
|
||||||
"title": "[Admin] - Erotic approvals",
|
"title": "[Admin] - Erotic approvals",
|
||||||
"intro": "Adult users can request access to the erotic area. Requests can be reviewed, approved or rejected here.",
|
"intro": "Adult users can request access to the erotic area. Requests can be reviewed, approved or rejected here.",
|
||||||
|
|||||||
@@ -487,6 +487,10 @@
|
|||||||
"score": "Score",
|
"score": "Score",
|
||||||
"review": "Review",
|
"review": "Review",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
|
"resetLessonProgress": "Reset lesson",
|
||||||
|
"resetLessonProgressConfirm": "Reset progress for this lesson? Saved state, exercise results, and trainer progress will be cleared. Other lessons stay unchanged.",
|
||||||
|
"resetLessonProgressSuccess": "Lesson progress was reset.",
|
||||||
|
"resetLessonProgressError": "Could not reset the lesson.",
|
||||||
"noLessons": "This course has no lessons yet.",
|
"noLessons": "This course has no lessons yet.",
|
||||||
"lessonNumber": "Lesson Number",
|
"lessonNumber": "Lesson Number",
|
||||||
"chapter": "Chapter",
|
"chapter": "Chapter",
|
||||||
|
|||||||
@@ -30,6 +30,20 @@
|
|||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"search": "Buscar"
|
"search": "Buscar"
|
||||||
},
|
},
|
||||||
|
"vocabLessonReset": {
|
||||||
|
"title": "Curso de idiomas: progreso de lección",
|
||||||
|
"intro": "Elimina el progreso, los resultados de ejercicios y el estado guardado de una sola lección (no todo el curso). Solo se listan cursos visibles para tu cuenta de administración (públicos o propios).",
|
||||||
|
"loadCourses": "Cargar cursos",
|
||||||
|
"selectCourse": "Curso",
|
||||||
|
"selectLesson": "Lección",
|
||||||
|
"reset": "Restablecer lección para este usuario",
|
||||||
|
"confirm": "¿Borrar de verdad el progreso de la lección «{lesson}» para {username}?",
|
||||||
|
"success": "Se restableció el progreso de la lección.",
|
||||||
|
"error": "No se pudo restablecer.",
|
||||||
|
"pickUserFirst": "Primero elige un usuario.",
|
||||||
|
"noCourses": "No hay cursos cargados o no hay cursos visibles.",
|
||||||
|
"loadingLessons": "Cargando lecciones…"
|
||||||
|
},
|
||||||
"adultVerification": {
|
"adultVerification": {
|
||||||
"title": "[Admin] - Aprobaciones eróticas",
|
"title": "[Admin] - Aprobaciones eróticas",
|
||||||
"intro": "Los usuarios adultos pueden solicitar acceso al área erótica. Aquí se revisan, aprueban o rechazan las solicitudes.",
|
"intro": "Los usuarios adultos pueden solicitar acceso al área erótica. Aquí se revisan, aprueban o rechazan las solicitudes.",
|
||||||
|
|||||||
@@ -485,6 +485,10 @@
|
|||||||
"score": "Puntuación",
|
"score": "Puntuación",
|
||||||
"review": "Repasar",
|
"review": "Repasar",
|
||||||
"start": "Empezar",
|
"start": "Empezar",
|
||||||
|
"resetLessonProgress": "Restablecer lección",
|
||||||
|
"resetLessonProgressConfirm": "¿Restablecer el progreso de esta lección? Se borrarán el estado guardado, los resultados de ejercicios y el progreso del entrenador. Las demás lecciones no cambian.",
|
||||||
|
"resetLessonProgressSuccess": "Se restableció el progreso de la lección.",
|
||||||
|
"resetLessonProgressError": "No se pudo restablecer la lección.",
|
||||||
"noLessons": "Este curso aún no tiene lecciones.",
|
"noLessons": "Este curso aún no tiene lecciones.",
|
||||||
"lessonNumber": "Número de lección",
|
"lessonNumber": "Número de lección",
|
||||||
"chapter": "Capítulo",
|
"chapter": "Capítulo",
|
||||||
|
|||||||
@@ -30,6 +30,56 @@
|
|||||||
<button @click="save">{{ $t('common.save') }}</button>
|
<button @click="save">{{ $t('common.save') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="selected" class="vocab-reset surface-card">
|
||||||
|
<h3 class="vocab-reset__title">{{ $t('admin.vocabLessonReset.title') }}</h3>
|
||||||
|
<p class="vocab-reset__intro">{{ $t('admin.vocabLessonReset.intro') }}</p>
|
||||||
|
|
||||||
|
<div class="vocab-reset__row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vocab-reset__btn-secondary"
|
||||||
|
:disabled="loadingVocabCourses"
|
||||||
|
@click="loadVocabCourses"
|
||||||
|
>
|
||||||
|
{{ loadingVocabCourses ? $t('general.loading') : $t('admin.vocabLessonReset.loadCourses') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label v-if="vocabCourses.length" class="edit__field">
|
||||||
|
<span>{{ $t('admin.vocabLessonReset.selectCourse') }}</span>
|
||||||
|
<select v-model="selectedVocabCourseId" @change="onVocabCourseChange">
|
||||||
|
<option value="">{{ $t('admin.vocabLessonReset.selectCourse') }}</option>
|
||||||
|
<option v-for="c in vocabCourses" :key="c.id" :value="String(c.id)">{{ c.title }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses" class="vocab-reset__hint">
|
||||||
|
{{ $t('admin.vocabLessonReset.noCourses') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label v-if="vocabCourseLessons.length" class="edit__field">
|
||||||
|
<span>{{ $t('admin.vocabLessonReset.selectLesson') }}</span>
|
||||||
|
<select v-model="selectedVocabLessonId" :disabled="loadingVocabCourseDetail">
|
||||||
|
<option value="">
|
||||||
|
{{ loadingVocabCourseDetail ? $t('admin.vocabLessonReset.loadingLessons') : $t('admin.vocabLessonReset.selectLesson') }}
|
||||||
|
</option>
|
||||||
|
<option v-for="l in vocabCourseLessons" :key="l.id" :value="String(l.id)">
|
||||||
|
{{ l.lessonNumber }}. {{ l.title }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="vocab-reset__row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:disabled="!selectedVocabLessonId || vocabResetSubmitting || loadingVocabCourseDetail"
|
||||||
|
@click="adminResetVocabLesson"
|
||||||
|
>
|
||||||
|
{{ vocabResetSubmitting ? $t('general.loading') : $t('admin.vocabLessonReset.reset') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -43,15 +93,31 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
selected: null,
|
selected: null,
|
||||||
form: { username: '', active: true }
|
form: { username: '', active: true },
|
||||||
|
vocabCourses: [],
|
||||||
|
vocabCoursesAttempted: false,
|
||||||
|
selectedVocabCourseId: '',
|
||||||
|
vocabCourseLessons: [],
|
||||||
|
selectedVocabLessonId: '',
|
||||||
|
loadingVocabCourses: false,
|
||||||
|
loadingVocabCourseDetail: false,
|
||||||
|
vocabResetSubmitting: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clearVocabResetUi() {
|
||||||
|
this.vocabCourses = [];
|
||||||
|
this.vocabCoursesAttempted = false;
|
||||||
|
this.selectedVocabCourseId = '';
|
||||||
|
this.vocabCourseLessons = [];
|
||||||
|
this.selectedVocabLessonId = '';
|
||||||
|
},
|
||||||
async select(u) {
|
async select(u) {
|
||||||
const res = await apiClient.get(`/api/admin/users/${u.id}`);
|
const res = await apiClient.get(`/api/admin/users/${u.id}`);
|
||||||
this.selected = res.data;
|
this.selected = res.data;
|
||||||
this.form.username = res.data.username;
|
this.form.username = res.data.username;
|
||||||
this.form.active = !!res.data.active;
|
this.form.active = !!res.data.active;
|
||||||
|
this.clearVocabResetUi();
|
||||||
},
|
},
|
||||||
toggleBlocked(e) {
|
toggleBlocked(e) {
|
||||||
this.form.active = !e.target.checked;
|
this.form.active = !e.target.checked;
|
||||||
@@ -63,6 +129,83 @@ export default {
|
|||||||
active: this.form.active
|
active: this.form.active
|
||||||
});
|
});
|
||||||
this.$root?.$refs?.messageDialog?.open?.('tr:common.saved');
|
this.$root?.$refs?.messageDialog?.open?.('tr:common.saved');
|
||||||
|
},
|
||||||
|
async loadVocabCourses() {
|
||||||
|
if (!this.selected) {
|
||||||
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.pickUserFirst'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingVocabCourses = true;
|
||||||
|
this.vocabCoursesAttempted = true;
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/vocab/courses');
|
||||||
|
this.vocabCourses = Array.isArray(data) ? data : [];
|
||||||
|
this.selectedVocabCourseId = '';
|
||||||
|
this.vocabCourseLessons = [];
|
||||||
|
this.selectedVocabLessonId = '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UsersView] vocab courses:', e);
|
||||||
|
this.vocabCourses = [];
|
||||||
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
||||||
|
} finally {
|
||||||
|
this.loadingVocabCourses = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onVocabCourseChange() {
|
||||||
|
this.selectedVocabLessonId = '';
|
||||||
|
this.vocabCourseLessons = [];
|
||||||
|
if (!this.selectedVocabCourseId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loadingVocabCourseDetail = true;
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get(`/api/vocab/courses/${this.selectedVocabCourseId}`);
|
||||||
|
const lessons = data?.lessons || [];
|
||||||
|
this.vocabCourseLessons = [...lessons].sort((a, b) => {
|
||||||
|
if (a.weekNumber !== b.weekNumber) {
|
||||||
|
return (a.weekNumber || 999) - (b.weekNumber || 999);
|
||||||
|
}
|
||||||
|
if (a.dayNumber !== b.dayNumber) {
|
||||||
|
return (a.dayNumber || 999) - (b.dayNumber || 999);
|
||||||
|
}
|
||||||
|
return (a.lessonNumber || 0) - (b.lessonNumber || 0);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UsersView] vocab course detail:', e);
|
||||||
|
this.vocabCourseLessons = [];
|
||||||
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
||||||
|
} finally {
|
||||||
|
this.loadingVocabCourseDetail = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
adminResetVocabLesson() {
|
||||||
|
if (!this.selected || !this.selectedVocabLessonId || this.vocabResetSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lesson = this.vocabCourseLessons.find((l) => String(l.id) === String(this.selectedVocabLessonId));
|
||||||
|
const lessonLabel = lesson ? `${lesson.lessonNumber}. ${lesson.title}` : this.selectedVocabLessonId;
|
||||||
|
const msg = this.$t('admin.vocabLessonReset.confirm', {
|
||||||
|
lesson: lessonLabel,
|
||||||
|
username: this.selected.username
|
||||||
|
});
|
||||||
|
if (!window.confirm(msg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.runAdminVocabReset();
|
||||||
|
},
|
||||||
|
async runAdminVocabReset() {
|
||||||
|
this.vocabResetSubmitting = true;
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/api/admin/users/${this.selected.id}/vocab-lesson-progress/reset`, {
|
||||||
|
lessonId: Number(this.selectedVocabLessonId)
|
||||||
|
});
|
||||||
|
this.$root?.$refs?.messageDialog?.open?.('tr:admin.vocabLessonReset.success');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UsersView] admin vocab reset:', e);
|
||||||
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
||||||
|
} finally {
|
||||||
|
this.vocabResetSubmitting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -76,7 +219,8 @@ export default {
|
|||||||
|
|
||||||
.admin-users__hero,
|
.admin-users__hero,
|
||||||
.admin-users__search,
|
.admin-users__search,
|
||||||
.edit {
|
.edit,
|
||||||
|
.vocab-reset {
|
||||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -85,7 +229,8 @@ export default {
|
|||||||
|
|
||||||
.admin-users__hero,
|
.admin-users__hero,
|
||||||
.admin-users__search,
|
.admin-users__search,
|
||||||
.edit {
|
.edit,
|
||||||
|
.vocab-reset {
|
||||||
padding: 22px 24px;
|
padding: 22px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +258,40 @@ export default {
|
|||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vocab-reset {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vocab-reset__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vocab-reset__intro,
|
||||||
|
.vocab-reset__hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vocab-reset__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vocab-reset__btn-secondary {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.edit__header {
|
.edit__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -141,6 +320,17 @@ export default {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit__field input,
|
||||||
|
.edit__field select {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.edit__toggle {
|
.edit__toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -156,7 +346,8 @@ export default {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.admin-users__hero,
|
.admin-users__hero,
|
||||||
.admin-users__search,
|
.admin-users__search,
|
||||||
.edit {
|
.edit,
|
||||||
|
.vocab-reset {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,4 +361,3 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<div class="lesson-header">
|
<div class="lesson-header">
|
||||||
<button @click="back" class="btn-back">{{ $t('general.back') }}</button>
|
<button @click="back" class="btn-back">{{ $t('general.back') }}</button>
|
||||||
<h2>{{ lesson.title }}</h2>
|
<h2>{{ lesson.title }}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-reset-lesson"
|
||||||
|
:disabled="resettingLessonProgress"
|
||||||
|
@click="confirmResetLessonProgress"
|
||||||
|
>
|
||||||
|
{{ resettingLessonProgress ? $t('general.loading') : $t('socialnetwork.vocab.courses.resetLessonProgress') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs für Lernen und Übungen -->
|
<!-- Tabs für Lernen und Übungen -->
|
||||||
@@ -942,7 +950,8 @@ export default {
|
|||||||
lessonStatePersistenceReady: false,
|
lessonStatePersistenceReady: false,
|
||||||
lessonStateSaveTimer: null,
|
lessonStateSaveTimer: null,
|
||||||
lessonStateSaveInFlight: false,
|
lessonStateSaveInFlight: false,
|
||||||
pendingLessonStatePayload: null
|
pendingLessonStatePayload: null,
|
||||||
|
resettingLessonProgress: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -1373,6 +1382,44 @@ export default {
|
|||||||
const userId = this.user?.id || 'guest';
|
const userId = this.user?.id || 'guest';
|
||||||
return `vocab-lesson-state:${LESSON_STATE_VERSION}:${userId}:${this.courseId}:${this.lessonId}`;
|
return `vocab-lesson-state:${LESSON_STATE_VERSION}:${userId}:${this.courseId}:${this.lessonId}`;
|
||||||
},
|
},
|
||||||
|
clearLocalLessonStateCache() {
|
||||||
|
const storageKey = this.getLessonStateStorageKey();
|
||||||
|
if (!storageKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(storageKey);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[VocabLessonView] Lokaler Lektions-Cache konnte nicht gelöscht werden:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmResetLessonProgress() {
|
||||||
|
if (!this.lessonId || this.resettingLessonProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(this.$t('socialnetwork.vocab.courses.resetLessonProgressConfirm'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resetLessonProgressOnServer();
|
||||||
|
},
|
||||||
|
async resetLessonProgressOnServer() {
|
||||||
|
if (!this.lessonId || this.resettingLessonProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resettingLessonProgress = true;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/vocab/lessons/${this.lessonId}/progress`);
|
||||||
|
this.clearLocalLessonStateCache();
|
||||||
|
this.$root?.$refs?.messageDialog?.open?.('tr:socialnetwork.vocab.courses.resetLessonProgressSuccess');
|
||||||
|
await this.loadLesson();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[VocabLessonView] Lektion zurücksetzen fehlgeschlagen:', e);
|
||||||
|
const msg = e?.response?.data?.error || this.$t('socialnetwork.vocab.courses.resetLessonProgressError');
|
||||||
|
this.$root?.$refs?.messageDialog?.open?.(msg);
|
||||||
|
} finally {
|
||||||
|
this.resettingLessonProgress = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
buildPersistedLessonState() {
|
buildPersistedLessonState() {
|
||||||
return {
|
return {
|
||||||
version: LESSON_STATE_VERSION,
|
version: LESSON_STATE_VERSION,
|
||||||
@@ -3104,6 +3151,29 @@ export default {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lesson-header h2 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset-lesson {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid rgba(140, 90, 60, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 248, 240, 0.95);
|
||||||
|
color: #6b4420;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset-lesson:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.lesson-overview-card {
|
.lesson-overview-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user