feat(localization): expand language support and enhance UI for user settings
All checks were successful
Deploy to production / deploy (push) Successful in 3m0s

- Added support for additional UI locales including Cebuano and Spanish, improving accessibility for a broader user base.
- Updated language selection components in the AppHeader and SettingsWidget to reflect new language options, enhancing user experience.
- Enhanced localization of various UI elements across components, ensuring consistent language representation and improved user engagement.
- Implemented logic to synchronize user language preferences with backend settings, providing a seamless experience when changing languages.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 07:54:44 +02:00
parent ac5d436a36
commit 6d9d69dc10
72 changed files with 1792 additions and 343 deletions

View File

@@ -4,7 +4,7 @@
<div v-else-if="course">
<section class="course-hero surface-card">
<div>
<span class="course-kicker">Lernkurs</span>
<span class="course-kicker">{{ $t('socialnetwork.vocab.courses.courseKicker') }}</span>
<h2>{{ course.title }}</h2>
<p v-if="course.description">{{ course.description }}</p>
</div>
@@ -41,13 +41,13 @@
<section v-if="course.lessons && course.lessons.length > 0" class="surface-card course-flow">
<div class="course-flow__header">
<div>
<span class="course-flow__eyebrow">Tagesfluss</span>
<h3>Heute sinnvoll weitermachen</h3>
<p>Die Reihenfolge folgt dem Konzept: fällige Wiederholung zuerst, dann aktueller Block, danach Intensivphase und freie Vertiefung.</p>
<span class="course-flow__eyebrow">{{ $t('socialnetwork.vocab.courses.courseFlowEyebrow') }}</span>
<h3>{{ $t('socialnetwork.vocab.courses.courseFlowTitle') }}</h3>
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntro') }}</p>
</div>
<div class="course-flow__stats">
<span class="course-flow__stat">Fällige Wiederholung: {{ dueReviewLessons.length }}</span>
<span class="course-flow__stat">Aktiver Block: {{ currentBlockNumber || '—' }}</span>
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowReviewStat', { count: dueReviewLessons.length }) }}</span>
<span class="course-flow__stat">{{ $t('socialnetwork.vocab.courses.courseFlowBlockStat', { block: currentBlockNumber || '—' }) }}</span>
</div>
</div>
@@ -56,8 +56,8 @@
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--review">1</span>
<div>
<h4>Fällige Wiederholung</h4>
<p>Bereits abgeschlossene Lektionen, die heute wieder drankommen sollten.</p>
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowReviewTitle') }}</h4>
<p>{{ $t('socialnetwork.vocab.courses.courseFlowReviewDescription') }}</p>
</div>
</div>
<div v-if="dueReviewLessons.length > 0" class="course-flow-card__list">
@@ -72,15 +72,15 @@
<span>{{ formatReviewDue(getLessonProgress(lesson.id)?.reviewNextDueAt) }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Heute ist keine ältere Lektion als fällige Wiederholung markiert.</p>
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowReviewEmpty') }}</p>
</article>
<article class="course-flow-card">
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--block">2</span>
<div>
<h4>Aktueller Block</h4>
<p>Hier liegt der nächste reguläre Fortschritt im Kurs.</p>
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowBlockTitle') }}</h4>
<p>{{ $t('socialnetwork.vocab.courses.courseFlowBlockDescription') }}</p>
</div>
</div>
<div v-if="currentBlockLessons.length > 0" class="course-flow-card__list">
@@ -95,15 +95,15 @@
<span>#{{ lesson.lessonNumber }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Der aktuelle Block ist bereits abgeschlossen oder es gibt gerade keine offene Blocklektion.</p>
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowBlockEmpty') }}</p>
</article>
<article class="course-flow-card">
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--intensive">3</span>
<div>
<h4>Fällige Intensivphase</h4>
<p>Verdichtete Wiederholung, sobald der Block davor weitgehend sitzt.</p>
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveTitle') }}</h4>
<p>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveDescription') }}</p>
</div>
</div>
<div v-if="nextIntensiveReviewLesson" class="course-flow-card__list">
@@ -113,18 +113,18 @@
@click="openLesson(nextIntensiveReviewLesson.id)"
>
<strong>{{ nextIntensiveReviewLesson.title }}</strong>
<span>Block {{ nextIntensiveReviewLesson.pedagogy?.blockNumber || '—' }}</span>
<span>{{ $t('socialnetwork.vocab.courses.lessonBlockLabel', { number: nextIntensiveReviewLesson.pedagogy?.blockNumber || '—' }) }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Aktuell ist keine neue Intensivphase freigeschaltet.</p>
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveEmpty') }}</p>
</article>
<article class="course-flow-card">
<div class="course-flow-card__top">
<span class="course-flow-card__badge course-flow-card__badge--practice">4</span>
<div>
<h4>Freie Vertiefung</h4>
<p>Abgeschlossene Lektionen für lockeres Nachtrainieren außerhalb des Pflichtpfads.</p>
<h4>{{ $t('socialnetwork.vocab.courses.courseFlowPracticeTitle') }}</h4>
<p>{{ $t('socialnetwork.vocab.courses.courseFlowPracticeDescription') }}</p>
</div>
</div>
<div v-if="freePracticeLessons.length > 0" class="course-flow-card__list">
@@ -136,10 +136,10 @@
@click="openLessonPractice(lesson)"
>
<strong>{{ lesson.title }}</strong>
<span>Im Trainer üben</span>
<span>{{ $t('socialnetwork.vocab.courses.practiceInTrainer') }}</span>
</button>
</div>
<p v-else class="course-flow-card__empty">Sobald du erste Lektionen abgeschlossen hast, erscheinen sie hier für freies Nachtrainieren.</p>
<p v-else class="course-flow-card__empty">{{ $t('socialnetwork.vocab.courses.courseFlowPracticeEmpty') }}</p>
</article>
</div>
</section>
@@ -157,7 +157,7 @@
</div>
<div class="lessons-header">
<h3>{{ $t('socialnetwork.vocab.courses.lessons') }}</h3>
<span class="lessons-count">{{ course.lessons.length }} Lektionen</span>
<span class="lessons-count">{{ $t('socialnetwork.vocab.courses.lessonsCount', { count: course.lessons.length }) }}</span>
</div>
<div class="lesson-cards">
<article v-for="lesson in sortedLessons" :key="lesson.id" class="lesson-card">
@@ -189,8 +189,8 @@
<div class="lesson-pedagogy" v-if="lesson.pedagogy">
<span class="lesson-chip lesson-chip--phase">{{ getPhaseLabel(lesson.pedagogy.phaseLabel) }}</span>
<span class="lesson-chip lesson-chip--mode">{{ getDidacticModeLabel(lesson.pedagogy.didacticMode) }}</span>
<span v-if="lesson.pedagogy.blockNumber" class="lesson-chip lesson-chip--block">Block {{ lesson.pedagogy.blockNumber }}</span>
<span v-if="lesson.pedagogy.isIntensiveReview" class="lesson-chip lesson-chip--intensive">Intensive Wiederholung</span>
<span v-if="lesson.pedagogy.blockNumber" class="lesson-chip lesson-chip--block">{{ $t('socialnetwork.vocab.courses.lessonBlockLabel', { number: lesson.pedagogy.blockNumber }) }}</span>
<span v-if="lesson.pedagogy.isIntensiveReview" class="lesson-chip lesson-chip--intensive">{{ $t('socialnetwork.vocab.courses.lessonIntensiveBadge') }}</span>
</div>
<div class="lesson-actions-content">
<button
@@ -206,7 +206,7 @@
@click="openLessonPractice(lesson)"
class="btn-edit"
>
Im Trainer üben
{{ $t('socialnetwork.vocab.courses.practiceInTrainer') }}
</button>
<button v-if="isOwner" @click="editLesson(lesson.id)" class="btn-edit">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
<button v-if="isOwner" @click="deleteLesson(lesson.id)" class="btn-delete">{{ $t('general.delete') }}</button>
@@ -245,7 +245,7 @@
<option v-for="chapter in chapters" :key="chapter.id" :value="chapter.id">{{ chapter.title }}</option>
</select>
</div>
<span v-if="lessonFormTouched && !canCreateLesson" class="form-error">Bitte Nummer, Titel und Kapitel vollständig angeben.</span>
<span v-if="lessonFormTouched && !canCreateLesson" class="form-error">{{ $t('socialnetwork.vocab.courses.addLessonValidation') }}</span>
<div class="form-actions form-actions-row">
<button type="submit" :disabled="!canCreateLesson">{{ $t('general.create') }}</button>
<button type="button" @click="showAddLessonDialog = false" class="button-secondary">{{ $t('general.cancel') }}</button>
@@ -460,44 +460,44 @@ export default {
formatDaysSince(dateString) {
const days = this.daysSince(dateString);
if (days <= 0) {
return 'heute';
return this.$t('socialnetwork.vocab.courses.timeToday');
}
if (days === 1) {
return 'seit 1 Tag';
return this.$t('socialnetwork.vocab.courses.timeSinceOneDay');
}
return `seit ${days} Tagen`;
return this.$t('socialnetwork.vocab.courses.timeSinceDays', { count: days });
},
formatReviewDue(reviewNextDueAt) {
if (!reviewNextDueAt) {
return 'jetzt fällig';
return this.$t('socialnetwork.vocab.courses.reviewDueNow');
}
const dueTimestamp = new Date(reviewNextDueAt).getTime();
if (!Number.isFinite(dueTimestamp)) {
return 'jetzt fällig';
return this.$t('socialnetwork.vocab.courses.reviewDueNow');
}
const diffMs = dueTimestamp - Date.now();
if (diffMs > 0) {
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
if (untilDays <= 1) {
return 'morgen fällig';
return this.$t('socialnetwork.vocab.courses.reviewDueTomorrow');
}
return `in ${untilDays} Tagen fällig`;
return this.$t('socialnetwork.vocab.courses.reviewDueInDays', { count: untilDays });
}
const diffDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
if (diffDays <= 0) {
return 'heute fällig';
return this.$t('socialnetwork.vocab.courses.reviewDueToday');
}
if (diffDays === 1) {
return 'seit 1 Tag fällig';
return this.$t('socialnetwork.vocab.courses.reviewDueSinceOneDay');
}
return `seit ${diffDays} Tagen fällig`;
return this.$t('socialnetwork.vocab.courses.reviewDueSinceDays', { count: diffDays });
},
getReviewStageLabel(progress) {
const stage = Number(progress?.reviewStage || 0);
if (stage === 0) return 'Tag 1';
if (stage === 1) return 'Tag 3';
if (stage === 2) return 'Tag 7';
if (stage >= 3) return 'Review abgeschlossen';
if (stage === 0) return this.$t('socialnetwork.vocab.courses.reviewStageDay1');
if (stage === 1) return this.$t('socialnetwork.vocab.courses.reviewStageDay3');
if (stage === 2) return this.$t('socialnetwork.vocab.courses.reviewStageDay7');
if (stage >= 3) return this.$t('socialnetwork.vocab.courses.reviewStageCompleted');
return '';
},
getReviewBadgeLabel(progress) {
@@ -564,15 +564,15 @@ export default {
chapterId: null
};
await this.loadCourse();
showSuccess(this, 'Lektion erfolgreich angelegt.');
showSuccess(this, this.$t('socialnetwork.vocab.courses.addLessonSuccess'));
} catch (e) {
console.error('Fehler beim Hinzufügen der Lektion:', e);
showApiError(this, e, 'Fehler beim Hinzufügen der Lektion');
showApiError(this, e, this.$t('socialnetwork.vocab.courses.addLessonError'));
}
},
async deleteLesson(lessonId) {
const confirmed = await confirmAction(this, {
title: 'Lektion löschen',
title: this.$t('socialnetwork.vocab.courses.deleteLessonTitle'),
message: this.$t('socialnetwork.vocab.courses.confirmDelete')
});
if (!confirmed) {
@@ -581,10 +581,10 @@ export default {
try {
await apiClient.delete(`/api/vocab/lessons/${lessonId}`);
await this.loadCourse();
showSuccess(this, 'Lektion erfolgreich gelöscht.');
showSuccess(this, this.$t('socialnetwork.vocab.courses.deleteLessonSuccess'));
} catch (e) {
console.error('Fehler beim Löschen der Lektion:', e);
showApiError(this, e, 'Fehler beim Löschen der Lektion');
showApiError(this, e, this.$t('socialnetwork.vocab.courses.deleteLessonError'));
}
},
openLesson(lessonId) {
@@ -608,37 +608,37 @@ export default {
getPhaseLabel(phaseLabel) {
switch (phaseLabel) {
case 'quickstart':
return 'Schnellstart';
return this.$t('socialnetwork.vocab.courses.phaseQuickstart');
case 'daily_life':
return 'Alltag';
return this.$t('socialnetwork.vocab.courses.phaseDailyLife');
case 'stabilization':
return 'Stabilisierung';
return this.$t('socialnetwork.vocab.courses.phaseStabilization');
default:
return 'Lernphase';
return this.$t('socialnetwork.vocab.courses.phaseDefault');
}
},
getDidacticModeLabel(didacticMode) {
switch (didacticMode) {
case 'core_input':
return 'Neuer Stoff';
return this.$t('socialnetwork.vocab.courses.didacticModeCoreInput');
case 'guided_dialogue':
return 'Geführter Dialog';
return this.$t('socialnetwork.vocab.courses.didacticModeGuidedDialogue');
case 'contrast_training':
return 'Kontrasttraining';
return this.$t('socialnetwork.vocab.courses.didacticModeContrastTraining');
case 'pattern_drill':
return 'Mustertraining';
return this.$t('socialnetwork.vocab.courses.didacticModePatternDrill');
case 'real_life_scenario':
return 'Alltagsszenario';
return this.$t('socialnetwork.vocab.courses.didacticModeRealLifeScenario');
case 'intensive_review':
return 'Wiederholungsphase';
return this.$t('socialnetwork.vocab.courses.didacticModeIntensiveReview');
case 'checkpoint':
return 'Checkpoint';
return this.$t('socialnetwork.vocab.courses.didacticModeCheckpoint');
default:
return 'Lerneinheit';
return this.$t('socialnetwork.vocab.courses.didacticModeDefault');
}
},
editLesson() {
showInfo(this, 'Die Bearbeitung einzelner Lektionen folgt noch.');
showInfo(this, this.$t('socialnetwork.vocab.courses.editLessonPending'));
}
},
async mounted() {