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

@@ -2,10 +2,10 @@
<div class="home-logged-in">
<section class="dashboard-hero surface-card">
<div class="dashboard-hero__copy">
<span class="dashboard-kicker">Dein Bereich</span>
<h1>Willkommen zurück!</h1>
<span class="dashboard-kicker">{{ $t('home.dashboard.kicker') }}</span>
<h1>{{ $t('home.dashboard.title') }}</h1>
<p class="dashboard-subtitle">
Dein persönlicher Einstieg in Community, Termine, Falukant und laufende Aktivitäten.
{{ $t('home.dashboard.subtitle') }}
</p>
</div>
<div class="dashboard-toolbar surface-card">
@@ -15,7 +15,7 @@
class="btn-edit"
@click="editMode = true"
>
Dashboard bearbeiten
{{ $t('home.dashboard.edit') }}
</button>
<template v-else>
<div class="widget-add-row">
@@ -24,7 +24,7 @@
class="widget-type-select"
@change="onSelectWidgetType"
>
<option value="">+ Widget hinzufügen </option>
<option value="">{{ $t('home.dashboard.addWidget') }}</option>
<option
v-for="wt in widgetTypeOptions"
:key="wt.id"
@@ -39,11 +39,11 @@
class="btn-add-again"
@click="addSameWidgetType"
>
Nochmal hinzufügen
{{ $t('home.dashboard.addAgain') }}
</button>
</div>
<button type="button" class="btn-done" @click="doneEditing">
Fertig
{{ $t('home.dashboard.done') }}
</button>
</template>
</div>
@@ -51,19 +51,19 @@
<section class="dashboard-overview">
<article class="overview-card surface-card">
<span class="overview-card__label">Aktive Widgets</span>
<span class="overview-card__label">{{ $t('home.dashboard.overview.activeWidgetsLabel') }}</span>
<strong>{{ widgets.length }}</strong>
<p>Dein Dashboard ist modular aufgebaut und kann jederzeit umsortiert werden.</p>
<p>{{ $t('home.dashboard.overview.activeWidgetsText') }}</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">Verfügbare Module</span>
<span class="overview-card__label">{{ $t('home.dashboard.overview.availableModulesLabel') }}</span>
<strong>{{ widgetTypeOptions.length }}</strong>
<p>Du kannst Community-, Kalender-, News- und Falukant-Module kombinieren.</p>
<p>{{ $t('home.dashboard.overview.availableModulesText') }}</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">Bearbeitungsmodus</span>
<strong>{{ editMode ? 'Aktiv' : 'Aus' }}</strong>
<p>{{ editMode ? 'Widgets können gerade ergänzt und angepasst werden.' : 'Inhalte bleiben fokussiert und ruhig lesbar.' }}</p>
<span class="overview-card__label">{{ $t('home.dashboard.overview.editModeLabel') }}</span>
<strong>{{ editMode ? $t('home.dashboard.overview.editModeActive') : $t('home.dashboard.overview.editModeInactive') }}</strong>
<p>{{ editMode ? $t('home.dashboard.overview.editModeActiveText') : $t('home.dashboard.overview.editModeInactiveText') }}</p>
</article>
</section>
@@ -85,8 +85,8 @@
>
<div class="dashboard-shell__header">
<div>
<h2>Deine Übersicht</h2>
<p>Widgets lassen sich verschieben und im Bearbeitungsmodus anpassen.</p>
<h2>{{ $t('home.dashboard.sectionTitle') }}</h2>
<p>{{ $t('home.dashboard.sectionIntro') }}</p>
</div>
</div>
<div
@@ -120,17 +120,17 @@
<input
v-model="w.title"
type="text"
placeholder="Titel"
:placeholder="$t('home.dashboard.widgetTitlePlaceholder')"
class="widget-edit-input"
/>
</div>
<button
type="button"
class="btn-remove"
title="Widget entfernen"
:title="$t('home.dashboard.removeWidget')"
@click="removeWidget(index)"
>
Entfernen
{{ $t('home.dashboard.remove') }}
</button>
</div>
</div>
@@ -139,7 +139,7 @@
</div>
<div v-if="widgets.length === 0 && !loading" class="dashboard-empty">
<p>Noch keine Widgets. Klicke auf „Dashboard bearbeiten“ und dann „+ Widget hinzufügen“.</p>
<p>{{ $t('home.dashboard.empty') }}</p>
</div>
<div class="actions">
@@ -164,7 +164,7 @@ export default {
computed: {
widgetTypeOptions() {
if (this.availableWidgets.length > 0) return this.availableWidgets;
return [{ id: 'default', label: 'Termine', endpoint: '/api/termine' }];
return [{ id: 'default', label: this.$t('home.dashboard.defaultAppointmentsWidget'), endpoint: '/api/termine' }];
}
},
data() {
@@ -185,6 +185,41 @@ export default {
this.loadAvailableWidgets();
},
methods: {
getLocalizedWidgetLabel(endpoint, fallbackLabel = '') {
const key = {
'/api/termine': 'home.dashboard.widgetLabels.appointments',
'/api/falukant/dashboard-widget': 'home.dashboard.widgetLabels.falukant',
'/api/news': 'home.dashboard.widgetLabels.news',
'/api/calendar/widget/birthdays': 'home.dashboard.widgetLabels.birthdays',
'/api/calendar/widget/upcoming': 'home.dashboard.widgetLabels.upcoming',
'/api/calendar/widget/mini': 'home.dashboard.widgetLabels.calendar'
}[endpoint];
return key ? this.$t(key) : fallbackLabel;
},
normalizeWidgetType(widgetType) {
return {
...widgetType,
label: this.getLocalizedWidgetLabel(widgetType?.endpoint, widgetType?.label || '')
};
},
normalizeWidgetConfig(widget) {
const localizedLabel = this.getLocalizedWidgetLabel(widget?.endpoint, '');
const title = String(widget?.title || '').trim();
const knownDefaultLabels = [
'Termine',
'Falukant',
'News',
'Geburtstage',
'Nächste Termine',
'Kalender'
];
return {
...widget,
title: !title || knownDefaultLabels.includes(title)
? (localizedLabel || title)
: title
};
},
/** Endpoint aus Widget-Typ (anhand gespeichertem endpoint gematcht), sonst w.endpoint. */
effectiveEndpoint(w) {
if (!w?.endpoint) return '';
@@ -204,7 +239,7 @@ export default {
async loadAvailableWidgets() {
try {
const { data } = await apiClient.get('/api/dashboard/widgets');
this.availableWidgets = Array.isArray(data) ? data : [];
this.availableWidgets = Array.isArray(data) ? data.map(widget => this.normalizeWidgetType(widget)) : [];
} catch (e) {
this.availableWidgets = [];
}
@@ -216,14 +251,14 @@ export default {
const { data } = await apiClient.get('/api/dashboard/config');
let list = Array.isArray(data?.widgets) ? [...data.widgets] : [];
if (list.length === 0) {
list = [{ id: generateId(), title: 'Termine', endpoint: '/api/termine' }];
list = [{ id: generateId(), title: this.$t('home.dashboard.defaultAppointmentsWidget'), endpoint: '/api/termine' }];
this.widgets = list;
await this.saveConfig();
} else {
this.widgets = list;
this.widgets = list.map(widget => this.normalizeWidgetConfig(widget));
}
} catch (e) {
this.loadError = e.response?.data?.error || e.message || 'Dashboard konnte nicht geladen werden.';
this.loadError = e.response?.data?.error || e.message || this.$t('home.dashboard.loadError');
} finally {
this.loading = false;
}
@@ -239,7 +274,7 @@ export default {
await apiClient.put('/api/dashboard/config', { widgets: payload });
} catch (e) {
console.error('Dashboard speichern fehlgeschlagen:', e);
this.saveError = e.response?.data?.error || e.message || 'Dashboard konnte nicht gespeichert werden.';
this.saveError = e.response?.data?.error || e.message || this.$t('home.dashboard.saveError');
}
},
addWidgetFromType(wt) {

View File

@@ -2,9 +2,9 @@
<div class="calendar-view">
<section class="calendar-hero surface-card">
<div>
<span class="calendar-kicker">Planung</span>
<span class="calendar-kicker">{{ $t('personal.calendar.kicker') }}</span>
<h2>{{ $t('personal.calendar.title') }}</h2>
<p>Termine, Geburtstage und eigene Einträge in einer strukturierten Übersicht.</p>
<p>{{ $t('personal.calendar.intro') }}</p>
</div>
</section>

View File

@@ -1,32 +1,37 @@
<template>
<section class="marketing-page">
<div class="hero">
<p class="eyebrow">Sprachen online lernen</p>
<h1>Der Vokabeltrainer auf YourPart kombiniert Lernen, Kurse und Übungen in einer Plattform.</h1>
<p class="eyebrow">{{ $t('home.vocabLanding.eyebrow') }}</p>
<h1>{{ $t('home.vocabLanding.title') }}</h1>
<p class="lead">
Arbeite mit interaktiven Lektionen, erweitere deinen Wortschatz und nutze strukturierte Inhalte für einen
motivierenden Lernfluss direkt im Browser.
{{ $t('home.vocabLanding.lead') }}
</p>
<router-link class="cta" to="/">Kostenlos starten</router-link>
<router-link class="cta" to="/">{{ $t('home.vocabLanding.cta') }}</router-link>
</div>
<div class="features">
<article>
<h2>Interaktive Kurse</h2>
<p>Kurse, Lektionen und Übungen helfen beim systematischen Aufbau neuer Sprachkenntnisse.</p>
<h2>{{ $t('home.vocabLanding.feature1Title') }}</h2>
<p>{{ $t('home.vocabLanding.feature1Text') }}</p>
</article>
<article>
<h2>Praxisorientiert</h2>
<p>Wortschatz, Grammatik und Wiederholung werden auf eine alltagstaugliche Lernroutine ausgerichtet.</p>
<h2>{{ $t('home.vocabLanding.feature2Title') }}</h2>
<p>{{ $t('home.vocabLanding.feature2Text') }}</p>
</article>
<article>
<h2>Teil einer Community</h2>
<p>Der Sprachbereich ist in eine größere Community-Plattform mit Blogs, Forum und Chat eingebettet.</p>
<h2>{{ $t('home.vocabLanding.feature3Title') }}</h2>
<p>{{ $t('home.vocabLanding.feature3Text') }}</p>
</article>
</div>
</section>
</template>
<script>
export default {
name: 'VocabLandingView'
};
</script>
<style scoped>
.marketing-page {
max-width: 1100px;

View File

@@ -228,6 +228,11 @@ export default {
display: grid;
gap: 18px;
max-width: 960px;
/* Flex-Kind von .app-content__inner: ohne overflow wird Inhalt bei min-height:0 abgeschnitten */
min-height: 0;
overflow-y: auto;
align-content: start;
-webkit-overflow-scrolling: touch;
}
.account-settings__hero,

View File

@@ -2,15 +2,15 @@
<div class="diary-view">
<section class="diary-hero surface-card">
<div>
<span class="diary-kicker">Persönliche Einträge</span>
<span class="diary-kicker">{{ $t('socialnetwork.diary.kicker') }}</span>
<h2>{{ $t('socialnetwork.diary.title') }}</h2>
<p>Gedanken, Notizen und kurze Updates in einer ruhigen, persönlichen Ansicht.</p>
<p>{{ $t('socialnetwork.diary.intro') }}</p>
</div>
</section>
<section class="new-entry-section surface-card">
<h3>{{ isEditing ? $t('socialnetwork.diary.editEntry') : $t('socialnetwork.diary.newEntry') }}</h3>
<textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea>
<textarea v-model="newEntryText" :placeholder="$t('socialnetwork.diary.placeholder')"></textarea>
<div class="form-actions">
<button @click="saveEntry">{{ isEditing ? $t('socialnetwork.diary.update') : $t('socialnetwork.diary.save')
}}</button>

View File

@@ -45,7 +45,7 @@
<label for="imageFile">{{ $t('socialnetwork.gallery.upload.image_file') }}</label>
<input type="file" accept="image/*" required @change="onFileChange" />
<div v-if="imagePreview" class="image-preview">
<img :src="imagePreview" alt="Image Preview" style="max-width: 150px; max-height: 150px;" />
<img :src="imagePreview" :alt="$t('socialnetwork.erotic.imagePreviewAlt')" style="max-width: 150px; max-height: 150px;" />
</div>
</div>
@@ -79,7 +79,7 @@
id="selectedUsers"
v-model="selectedUsernamesText"
type="text"
placeholder="anna, bert, clara"
:placeholder="$t('socialnetwork.erotic.selectedUsersPlaceholder')"
/>
</div>
@@ -95,7 +95,7 @@
<ul v-if="images.length > 0" class="image-grid">
<li v-for="image in images" :key="image.id" class="erotic-image-card">
<div class="erotic-image-card__preview" @click="!image.isModeratedHidden && openImageDialog(image)">
<img v-if="!image.isModeratedHidden" :src="image.url || image.placeholder" alt="Loading..." />
<img v-if="!image.isModeratedHidden" :src="image.url || image.placeholder" :alt="$t('socialnetwork.erotic.imageLoadingAlt')" />
<div v-else class="erotic-image-card__hidden">
{{ $t('socialnetwork.erotic.hiddenByModeration') }}
</div>

View File

@@ -6,7 +6,7 @@
<div>
<span class="erotic-videos-eyebrow">{{ $t('socialnetwork.erotic.eyebrow') }}</span>
<h2>{{ isForeignView ? `${$t('socialnetwork.erotic.videosTitle')} · ${viewUsername}` : $t('socialnetwork.erotic.videosTitle') }}</h2>
<p>{{ isForeignView ? 'Freigegebene Videos aus dem Erwachsenenbereich.' : $t('socialnetwork.erotic.videosIntro') }}</p>
<p>{{ isForeignView ? $t('socialnetwork.erotic.foreignVideosIntro') : $t('socialnetwork.erotic.videosIntro') }}</p>
</div>
</section>
@@ -53,14 +53,14 @@
</label>
<label v-if="requiresSelectedUsers">
<span>{{ $t('socialnetwork.gallery.visibility.selected-users') }}</span>
<input v-model="selectedUsernamesText" type="text" placeholder="anna, bert, clara" />
<input v-model="selectedUsernamesText" type="text" :placeholder="$t('socialnetwork.erotic.selectedUsersPlaceholder')" />
</label>
<label>
<span>{{ $t('socialnetwork.erotic.videoFile') }}</span>
<input type="file" accept="video/mp4,video/webm,video/ogg,video/quicktime" required @change="onFileChange" />
</label>
<div class="erotic-videos-upload__meta">
<span>MP4, WEBM, OGG, MOV</span>
<span>{{ $t('socialnetwork.erotic.videoFormats') }}</span>
<span v-if="fileToUpload">{{ fileToUpload.name }}</span>
</div>
<button type="submit">{{ $t('socialnetwork.gallery.upload.upload_button') }}</button>
@@ -68,34 +68,34 @@
</section>
<section class="erotic-videos-panel surface-card">
<h3>Bibliothek</h3>
<h3>{{ $t('socialnetwork.erotic.libraryTitle') }}</h3>
<div class="erotic-videos-panel__list">
<div class="erotic-videos-panel__item">
<span>{{ isForeignView ? 'Freigegebene Videos' : $t('socialnetwork.erotic.myVideos') }}</span>
<span>{{ isForeignView ? $t('socialnetwork.erotic.sharedVideos') : $t('socialnetwork.erotic.myVideos') }}</span>
<strong>{{ videos.length }}</strong>
</div>
<div class="erotic-videos-panel__item">
<span>Letzter Upload</span>
<span>{{ $t('socialnetwork.erotic.latestUpload') }}</span>
<strong>{{ latestVideoTitle }}</strong>
</div>
<div class="erotic-videos-panel__item">
<span>Sichtbare Videos</span>
<span>{{ $t('socialnetwork.erotic.visibleVideos') }}</span>
<strong>{{ visibleVideosCount }}</strong>
</div>
<div class="erotic-videos-panel__item">
<span>Moderationsfälle</span>
<span>{{ $t('socialnetwork.erotic.moderationCases') }}</span>
<strong>{{ hiddenVideosCount }}</strong>
</div>
</div>
</section>
<section class="erotic-videos-panel surface-card">
<h3>Hinweise</h3>
<h3>{{ $t('socialnetwork.erotic.notesTitle') }}</h3>
<ul class="erotic-videos-checklist">
<li>{{ isForeignView ? 'Du siehst hier nur Videos, die dir für den Erwachsenenbereich freigegeben wurden.' : $t('socialnetwork.erotic.videoUploadHint') }}</li>
<li>Freunde sehen Inhalte nur dann, wenn sie volljährig und für den Erwachsenenbereich freigeschaltet sind.</li>
<li>Gezielt freigegebene Personen müssen ebenfalls volljährig und freigeschaltet sein.</li>
<li>Nutze {{ $t('socialnetwork.erotic.reportAction') }} direkt am jeweiligen Eintrag, wenn Inhalte geprüft werden sollen.</li>
<li>{{ isForeignView ? $t('socialnetwork.erotic.foreignVideosOnlyHint') : $t('socialnetwork.erotic.videoUploadHint') }}</li>
<li>{{ $t('socialnetwork.erotic.friendsVisibilityHint') }}</li>
<li>{{ $t('socialnetwork.erotic.selectedUsersVisibilityHint') }}</li>
<li>{{ $t('socialnetwork.erotic.reportHint', { action: $t('socialnetwork.erotic.reportAction') }) }}</li>
</ul>
</section>
</aside>
@@ -103,15 +103,15 @@
<section class="erotic-videos-library surface-card">
<div class="erotic-videos-library__header">
<div>
<h3>{{ isForeignView ? 'Freigegebene Videos' : $t('socialnetwork.erotic.myVideos') }}</h3>
<p>{{ isForeignView ? 'Sichtbare Videos aus freigegebenen Erwachsenenbereichen.' : 'Eigene Uploads, Freigaben und Meldungen an einem Ort.' }}</p>
<h3>{{ isForeignView ? $t('socialnetwork.erotic.sharedVideos') : $t('socialnetwork.erotic.myVideos') }}</h3>
<p>{{ isForeignView ? $t('socialnetwork.erotic.sharedVideosIntro') : $t('socialnetwork.erotic.libraryIntro') }}</p>
</div>
<span class="erotic-videos-library__count">{{ videos.length }}</span>
</div>
<div v-if="videos.length === 0" class="erotic-videos-empty">
<strong>{{ $t('socialnetwork.erotic.noVideos') }}</strong>
<span>{{ isForeignView ? 'Für dich sind aktuell keine freigegebenen Videos vorhanden.' : 'Lege links dein erstes Video an und verwalte es danach hier in der Bibliothek.' }}</span>
<span>{{ isForeignView ? $t('socialnetwork.erotic.noSharedVideos') : $t('socialnetwork.erotic.libraryEmptyHint') }}</span>
</div>
<div v-else class="erotic-videos-library__scroll">
@@ -122,7 +122,7 @@
{{ $t('socialnetwork.erotic.hiddenByModeration') }}
</div>
<div class="erotic-videos-card__meta">
<strong>{{ video.title || 'Ohne Titel' }}</strong>
<strong>{{ video.title || $t('socialnetwork.erotic.untitled') }}</strong>
<span v-if="video.createdAtLabel">{{ video.createdAtLabel }}</span>
</div>
<div v-if="video.visibilities?.length" class="erotic-videos-card__visibility">
@@ -136,7 +136,7 @@
<p v-if="video.description">{{ video.description }}</p>
<div v-if="!isForeignView" class="erotic-videos-card__edit">
<button type="button" class="secondary" @click="toggleEditor(video.id)">
{{ editingVideoId === video.id ? 'Bearbeitung schließen' : 'Freigaben bearbeiten' }}
{{ editingVideoId === video.id ? $t('socialnetwork.erotic.closeEditing') : $t('socialnetwork.erotic.editVisibility') }}
</button>
<div v-if="editingVideoId === video.id" class="erotic-videos-editor">
<label>
@@ -173,10 +173,10 @@
</label>
<label v-if="editRequiresSelectedUsers">
<span>{{ $t('socialnetwork.gallery.visibility.selected-users') }}</span>
<input v-model="editForm.selectedUsernamesText" type="text" placeholder="anna, bert, clara" />
<input v-model="editForm.selectedUsernamesText" type="text" :placeholder="$t('socialnetwork.erotic.selectedUsersPlaceholder')" />
</label>
<div class="erotic-videos-editor__actions">
<button type="button" @click="saveVideo(video.id)">Speichern</button>
<button type="button" @click="saveVideo(video.id)">{{ $t('common.save') }}</button>
</div>
</div>
</div>
@@ -265,7 +265,7 @@ export default {
return this.videos.filter((video) => video.isModeratedHidden).length;
},
latestVideoTitle() {
return this.videos[0]?.title || 'Noch kein Upload';
return this.videos[0]?.title || this.$t('socialnetwork.erotic.noUploadYet');
}
},
async mounted() {

View File

@@ -4,7 +4,7 @@
<div>
<div class="forum-topic-back link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</div>
<h2 v-if="forumTopic">{{ forumTopic }}</h2>
<p>Diskussionen, Antworten und neue Beiträge in einer fokussierten Lesefläche.</p>
<p>{{ $t('socialnetwork.forum.topicIntro') }}</p>
</div>
</section>

View File

@@ -2,9 +2,9 @@
<div class="forum-view">
<section class="forum-hero surface-card">
<div>
<span class="forum-kicker">Community-Forum</span>
<span class="forum-kicker">{{ $t('socialnetwork.forum.kicker') }}</span>
<h2>{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
<p>Themen, Diskussionen und neue Beiträge an einem strukturierten Ort.</p>
<p>{{ $t('socialnetwork.forum.intro') }}</p>
</div>
<div class="creationtoggler">
<button @click="toggleCreation">
@@ -18,10 +18,10 @@
<section v-if="inCreation" class="forum-creation surface-card">
<div class="forum-creation__header">
<div>
<h3>Neues Thema verfassen</h3>
<p>Erst Titel setzen, dann den Beitrag schreiben und anschließend direkt veröffentlichen.</p>
<h3>{{ $t('socialnetwork.forum.createTitle') }}</h3>
<p>{{ $t('socialnetwork.forum.createIntro') }}</p>
</div>
<button type="button" class="button-secondary" @click="cancelCreation">Abbrechen</button>
<button type="button" class="button-secondary" @click="cancelCreation">{{ $t('socialnetwork.forum.cancelCreation') }}</button>
</div>
<label class="newtitle">
<span>{{ $t('socialnetwork.forum.topic') }}</span>
@@ -34,7 +34,7 @@
<button :disabled="!canSaveTopic" @click="saveNewTopic">
{{ $t('socialnetwork.forum.createNewTopic') }}
</button>
<span class="forum-creation__hint">Titel und Inhalt müssen beide ausgefüllt sein.</span>
<span class="forum-creation__hint">{{ $t('socialnetwork.forum.creationHint') }}</span>
</div>
</section>
@@ -44,7 +44,7 @@
<button type="button" class="topic-card__main" @click="openTopic(topic.id)">
<strong>{{ topic.title }}</strong>
<span class="topic-card__meta">
{{ topic.user?.username || topic.owner?.username || 'Community' }}
{{ topic.user?.username || topic.owner?.username || $t('socialnetwork.forum.communityFallback') }}
</span>
</button>
</li>
@@ -175,10 +175,10 @@ export default {
this.inCreation = false
this.newTitle = ''
this.editor?.commands.setContent('')
showSuccess(this, 'Thema erfolgreich erstellt.')
showSuccess(this, this.$t('socialnetwork.forum.topicCreated'))
} catch (err) {
console.error('Fehler beim Erstellen des Themas', err)
showApiError(this, err, 'Fehler beim Erstellen des Themas')
showApiError(this, err, this.$t('socialnetwork.forum.topicCreateError'))
}
},
goToPage(page) {

View File

@@ -2,18 +2,18 @@
<div class="friends-view">
<section class="friends-hero surface-card">
<div>
<span class="friends-kicker">Community</span>
<span class="friends-kicker">{{ $t('friends.kicker') }}</span>
<h2>{{ $t('friends.title') }}</h2>
<p>Freundschaften, offene Anfragen und laufende Kontakte an einem Ort.</p>
<p>{{ $t('friends.intro') }}</p>
</div>
<div class="friends-stats">
<div class="friends-stat surface-card">
<strong>{{ tabs[0].data.length }}</strong>
<span>Bestehend</span>
<span>{{ $t('friends.stats.existing') }}</span>
</div>
<div class="friends-stat surface-card">
<strong>{{ tabs[1].data.length + tabs[2].data.length }}</strong>
<span>Offen</span>
<span>{{ $t('friends.stats.open') }}</span>
</div>
</div>
</section>

View File

@@ -2,9 +2,9 @@
<div class="gallery-page">
<section class="gallery-hero surface-card">
<div>
<span class="gallery-kicker">Bilder und Ordner</span>
<span class="gallery-kicker">{{ $t('socialnetwork.gallery.kicker') }}</span>
<h2>{{ $t('socialnetwork.gallery.title') }}</h2>
<p>Eigene Inhalte organisieren, sichtbar machen und in Ordnern strukturieren.</p>
<p>{{ $t('socialnetwork.gallery.intro') }}</p>
</div>
</section>
<div class="gallery-view">
@@ -39,7 +39,7 @@
<label for="imageFile">{{ $t('socialnetwork.gallery.upload.image_file') }}</label>
<input type="file" @change="onFileChange" accept="image/*" required />
<div v-if="imagePreview" class="image-preview">
<img :src="imagePreview" alt="Image Preview"
<img :src="imagePreview" :alt="$t('socialnetwork.gallery.imagePreviewAlt')"
style="max-width: 150px; max-height: 150px;" />
</div>
</div>
@@ -74,7 +74,7 @@
<h3>{{ $t('socialnetwork.gallery.images') }}</h3>
<ul v-if="images.length > 0" class="image-grid">
<li v-for="image in images" :key="image.id" @click="openImageDialog(image)">
<img :src="image.url || image.placeholder" alt="Loading..." />
<img :src="image.url || image.placeholder" :alt="$t('socialnetwork.gallery.imageLoadingAlt')" />
<p>{{ image.title }}</p>
</li>
</ul>

View File

@@ -2,9 +2,9 @@
<div class="guestbook-view">
<section class="guestbook-hero surface-card">
<div>
<span class="guestbook-kicker">Gästebuch</span>
<span class="guestbook-kicker">{{ $t('socialnetwork.guestbook.kicker') }}</span>
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2>
<p>Nachrichten, Rückmeldungen und kleine Einblicke aus deinem Netzwerk.</p>
<p>{{ $t('socialnetwork.guestbook.intro') }}</p>
</div>
</section>
<div v-if="guestbookEntries.length === 0" class="guestbook-empty surface-card">{{ $t('socialnetwork.profile.guestbook.noEntries') }}

View File

@@ -2,9 +2,9 @@
<div class="search-view">
<section class="search-hero surface-card">
<div>
<span class="search-kicker">Community-Suche</span>
<span class="search-kicker">{{ $t('socialnetwork.usersearch.kicker') }}</span>
<h2>{{ $t('socialnetwork.usersearch.title') }}</h2>
<p>Mit Namen, Alter und Geschlecht gezielt passende Kontakte in der Community finden.</p>
<p>{{ $t('socialnetwork.usersearch.intro') }}</p>
</div>
</section>
@@ -22,7 +22,7 @@
<div class="age-range">
<input type="number" id="ageFrom" v-model="searchCriteria.ageFrom" :min="14" :max="150"
:placeholder="$t('socialnetwork.usersearch.age_from')" class="age-input" />
<span class="age-separator">bis</span>
<span class="age-separator">{{ $t('socialnetwork.usersearch.ageSeparator') }}</span>
<input type="number" id="ageTo" v-model="searchCriteria.ageTo" :min="14" :max="150"
:placeholder="$t('socialnetwork.usersearch.age_to')" class="age-input" />
</div>
@@ -45,7 +45,7 @@
<section class="search-results surface-card" v-if="searchResults.length">
<div class="results-header">
<h3>{{ $t('socialnetwork.usersearch.results_title') }}</h3>
<span class="results-count">{{ searchResults.length }} Treffer</span>
<span class="results-count">{{ $t('socialnetwork.usersearch.resultsCount', { count: searchResults.length }) }}</span>
</div>
<div class="result-cards">
<article v-for="result in searchResults" :key="result.id" class="result-card">
@@ -57,7 +57,7 @@
</div>
</div>
<button type="button" class="button-secondary" @click="openUserProfile(result.id)">
Profil öffnen
{{ $t('socialnetwork.usersearch.openProfile') }}
</button>
</article>
</div>

View File

@@ -1,9 +1,9 @@
<template>
<div class="vocab-chapter-view">
<section class="vocab-chapter-hero surface-card">
<span class="vocab-chapter-hero__eyebrow">Vokabeltrainer</span>
<span class="vocab-chapter-hero__eyebrow">{{ $t('socialnetwork.vocab.chapterHeroEyebrow') }}</span>
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
<p>Kapitelinhalt durchsuchen, Vokabeln pflegen und direkt in die Übung wechseln.</p>
<p>{{ $t('socialnetwork.vocab.chapterHeroIntro') }}</p>
</section>
<section class="box surface-card">

View File

@@ -2,9 +2,9 @@
<div class="vocab-course-list">
<section class="vocab-courses-hero surface-card">
<div>
<span class="vocab-courses-kicker">Kurse</span>
<span class="vocab-courses-kicker">{{ $t('socialnetwork.vocab.courses.courseListKicker') }}</span>
<h2>{{ $t('socialnetwork.vocab.courses.title') }}</h2>
<p>Öffentliche und eigene Lernkurse filtern, finden und direkt weiterlernen.</p>
<p>{{ $t('socialnetwork.vocab.courses.courseListIntro') }}</p>
</div>
</section>
@@ -88,7 +88,7 @@
<form @submit.prevent="findCourseByCode">
<div class="form-group">
<label>{{ $t('socialnetwork.vocab.courses.shareCode') }}</label>
<input v-model="shareCode" placeholder="z.B. abc123def456" required />
<input v-model="shareCode" :placeholder="$t('socialnetwork.vocab.courses.courseShareCodePlaceholder')" required />
</div>
<div class="form-actions">
<button type="submit">{{ $t('general.search') }}</button>
@@ -329,7 +329,7 @@ export default {
await this.loadAllCourses();
} catch (e) {
console.error('Fehler beim Erstellen des Kurses:', e);
showApiError(this, e, 'Fehler beim Erstellen des Kurses');
showApiError(this, e, this.$t('socialnetwork.vocab.courses.createCourseError'));
}
},
async enroll(courseId) {
@@ -339,7 +339,7 @@ export default {
this.openCourse(courseId);
} catch (e) {
console.error('Fehler beim Einschreiben:', e);
showApiError(this, e, 'Fehler beim Einschreiben');
showApiError(this, e, this.$t('socialnetwork.vocab.courses.enrollCourseError'));
}
},
openCourse(courseId) {

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() {

View File

@@ -4,9 +4,9 @@
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!language">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<span class="vocab-language-kicker">Sprache</span>
<span class="vocab-language-kicker">{{ $t('socialnetwork.vocab.languageHeroEyebrow') }}</span>
<h2>{{ $t('socialnetwork.vocab.languageTitle', { name: language?.name || '' }) }}</h2>
<p>Kapitel, Suchfunktionen und Freigaben für diese Sprache an einem Ort.</p>
<p>{{ $t('socialnetwork.vocab.languageHeroIntro') }}</p>
</div>
</section>

View File

@@ -42,7 +42,7 @@
<strong>{{ getLessonTypeLabel(lesson.lessonType) }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.didacticMode">
<span class="lesson-meta-label">Fokus</span>
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaFocus') }}</span>
<strong>{{ getDidacticModeLabel(lessonPedagogy.didacticMode) }}</strong>
</div>
<div class="lesson-meta-item">
@@ -74,15 +74,15 @@
</summary>
<div class="lesson-overview-more__grid">
<div class="lesson-meta-item" v-if="lessonPedagogy.phaseLabel">
<span class="lesson-meta-label">Phase</span>
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaPhase') }}</span>
<strong>{{ getPhaseLabel(lessonPedagogy.phaseLabel) }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.newUnitTarget">
<span class="lesson-meta-label">Neue Einheiten</span>
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaNewUnits') }}</span>
<strong>{{ lessonPedagogy.newUnitTarget }}</strong>
</div>
<div class="lesson-meta-item" v-if="lessonPedagogy.reviewWeight != null">
<span class="lesson-meta-label">Wiederholung</span>
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonMetaReview') }}</span>
<strong>{{ lessonPedagogy.reviewWeight }}%</strong>
</div>
</div>
@@ -90,8 +90,8 @@
</div>
<div v-if="lessonPedagogy.isIntensiveReview" class="lesson-intensity-banner">
<strong>Intensive Wiederholungsphase</strong>
<p>Diese Lektion priorisiert Wiederholung und Vertiefung. Neuer Stoff wird bewusst reduziert, damit vorhandene Muster stabil werden.</p>
<strong>{{ $t('socialnetwork.vocab.courses.intensiveReviewTitle') }}</strong>
<p>{{ $t('socialnetwork.vocab.courses.intensiveReviewIntro') }}</p>
</div>
<section class="lesson-primary-flow surface-card">
@@ -173,18 +173,18 @@
<div v-if="trainableLessonVocab.length > 0" class="vocab-trainer-section">
<h4>{{ $t('socialnetwork.vocab.courses.vocabTrainer') }}</h4>
<div v-if="hasPreviousVocab" class="review-priority-note">
<strong>Wiederholung läuft schrittweise mit</strong>
<p>Zuerst liegt der Fokus auf den neuen Begriffen dieser Lektion. Mit deinem Fortschritt fließen ältere Vokabeln dann zunehmend mit ein.</p>
<strong>{{ $t('socialnetwork.vocab.courses.reviewPriorityTitle') }}</strong>
<p>{{ $t('socialnetwork.vocab.courses.reviewPriorityIntro') }}</p>
</div>
<div v-if="hasExercises && !canAccessExercises" class="exercise-lock-note">
<strong>Kapitel-Prüfung noch gesperrt</strong>
<strong>{{ $t('socialnetwork.vocab.courses.exerciseLockTitle') }}</strong>
<p>{{ exerciseUnlockHint }}</p>
</div>
<div v-if="!vocabTrainerActive" class="vocab-trainer-start">
<template v-if="canStartVocabTrainerPrep">
<p>{{ hasPreviousVocab ? 'Starte mit den neuen Vokabeln dieser Lektion. Mit fortschreitendem Üben mischt der Trainer automatisch passende Wiederholungen ein.' : $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
<p>{{ hasPreviousVocab ? $t('socialnetwork.vocab.courses.trainerStartWithReview') : $t('socialnetwork.vocab.courses.vocabTrainerDescription') }}</p>
<button @click="startVocabTrainer" class="btn-start-trainer">
{{ hasPreviousVocab ? 'Lektion starten' : $t('socialnetwork.vocab.courses.startVocabTrainer') }}
{{ hasPreviousVocab ? $t('socialnetwork.vocab.courses.startLesson') : $t('socialnetwork.vocab.courses.startVocabTrainer') }}
</button>
</template>
<p v-else class="vocab-trainer-locked-hint">{{ $t('socialnetwork.vocab.courses.vocabTrainerLockedHint') }}</p>
@@ -201,10 +201,10 @@
</div>
<div class="stats-row">
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'current' }">
{{ $t('socialnetwork.vocab.courses.currentLesson') || 'Aktuelle Lektion' }}
{{ $t('socialnetwork.vocab.courses.currentLesson') }}
</span>
<span v-if="previousVocab && previousVocab.length > 0" class="mode-badge" :class="{ 'mode-active': vocabTrainerPhase === 'mixed' }">
{{ $t('socialnetwork.vocab.courses.mixedReview') || 'Gemischt' }}
{{ $t('socialnetwork.vocab.courses.mixedReview') }}
</span>
<span class="mode-badge" :class="{ 'mode-active': vocabTrainerMode === 'multiple_choice', 'mode-completed': vocabTrainerMode === 'typing' }">
{{ $t('socialnetwork.vocab.courses.modeMultipleChoice') }}
@@ -215,9 +215,9 @@
<button @click="stopVocabTrainer" class="btn-stop-trainer">{{ $t('socialnetwork.vocab.courses.stopTrainer') }}</button>
</div>
<div v-if="hasPreviousVocab" class="stats-row trainer-progress-row">
<span>Neue Inhalte: {{ vocabTrainerCurrentAttempts }}/{{ trainerNewFocusTarget }}</span>
<span>Wiederholung: {{ vocabTrainerReviewAttempts }}</span>
<span>Mischanteil: {{ Math.round(currentReviewShare * 100) }}%</span>
<span>{{ $t('socialnetwork.vocab.courses.trainerProgressNewContent', { current: vocabTrainerCurrentAttempts, target: trainerNewFocusTarget }) }}</span>
<span>{{ $t('socialnetwork.vocab.courses.trainerProgressReview', { count: vocabTrainerReviewAttempts }) }}</span>
<span>{{ $t('socialnetwork.vocab.courses.trainerProgressMixShare', { percent: Math.round(currentReviewShare * 100) }) }}</span>
</div>
</div>
<div v-if="currentVocabQuestion" class="vocab-question">
@@ -796,8 +796,8 @@
<!-- Fallback für unbekannte Typen -->
<div v-else class="unknown-exercise">
<p>Dieser Übungstyp wird in der aktuellen Ansicht noch nicht interaktiv dargestellt.</p>
<p class="unknown-exercise__type">Typ: {{ getExerciseType(exercise) }}</p>
<p>{{ $t('socialnetwork.vocab.courses.unknownExerciseTypeNotice') }}</p>
<p class="unknown-exercise__type">{{ $t('socialnetwork.vocab.courses.unknownExerciseTypeLabel', { type: getExerciseType(exercise) }) }}</p>
</div>
</div>
</div>
@@ -1231,12 +1231,7 @@ export default {
if (!progress?.completed) {
return '';
}
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';
return '';
return this.getReviewStageLabel(progress);
},
lessonReviewStatusClass() {
const progress = this.lessonProgress;
@@ -1257,12 +1252,12 @@ export default {
return '';
}
if (progress.reviewCompleted) {
return 'Diese Lektion ist in der freien Vertiefung angekommen.';
return this.$t('socialnetwork.vocab.courses.lessonReviewHeadlineDone');
}
if (progress.reviewDue) {
return 'Diese Review-Welle ist jetzt fällig.';
return this.$t('socialnetwork.vocab.courses.lessonReviewHeadlineDue');
}
return 'Diese Lektion ist für die nächste Review-Welle vorgemerkt.';
return this.$t('socialnetwork.vocab.courses.lessonReviewHeadlineScheduled');
},
lessonReviewHint() {
const progress = this.lessonProgress;
@@ -1270,9 +1265,11 @@ export default {
return '';
}
if (progress.reviewCompleted) {
return 'Die 1/3/7-Tage-Wiederholung ist abgeschlossen. Du kannst die Lektion jetzt flexibel weitertrainieren.';
return this.$t('socialnetwork.vocab.courses.lessonReviewHintDone');
}
return `Nächste Fälligkeit: ${this.formatLessonReviewDue(progress.reviewNextDueAt)}.`;
return this.$t('socialnetwork.vocab.courses.lessonReviewHintNextDue', {
due: this.formatLessonReviewDue(progress.reviewNextDueAt)
});
},
assistantAvailable() {
if (!this.assistantSettings) {
@@ -2019,35 +2016,43 @@ 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 'Lernfokus';
return this.$t('socialnetwork.vocab.courses.didacticModeFocusDefault');
}
},
getReviewStageLabel(progress) {
const stage = Number(progress?.reviewStage || 0);
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 '';
},
formatTargetMinutes(targetMinutes) {
const minutes = Number(targetMinutes);
if (!minutes) {
@@ -2057,28 +2062,28 @@ export default {
},
formatLessonReviewDue(reviewNextDueAt) {
if (!reviewNextDueAt) {
return 'jetzt';
return this.$t('socialnetwork.vocab.courses.reviewTimeNow');
}
const dueTimestamp = new Date(reviewNextDueAt).getTime();
if (!Number.isFinite(dueTimestamp)) {
return 'jetzt';
return this.$t('socialnetwork.vocab.courses.reviewTimeNow');
}
const diffMs = dueTimestamp - Date.now();
if (diffMs > 0) {
const untilDays = Math.ceil(diffMs / (24 * 60 * 60 * 1000));
if (untilDays <= 1) {
return 'morgen';
return this.$t('socialnetwork.vocab.courses.reviewTimeTomorrow');
}
return `in ${untilDays} Tagen`;
return this.$t('socialnetwork.vocab.courses.reviewTimeInDays', { count: untilDays });
}
const overdueDays = Math.floor((Date.now() - dueTimestamp) / (24 * 60 * 60 * 1000));
if (overdueDays <= 0) {
return 'heute';
return this.$t('socialnetwork.vocab.courses.timeToday');
}
if (overdueDays === 1) {
return 'seit 1 Tag';
return this.$t('socialnetwork.vocab.courses.timeSinceOneDay');
}
return `seit ${overdueDays} Tagen`;
return this.$t('socialnetwork.vocab.courses.timeSinceDays', { count: overdueDays });
},
getQuestionData(exercise) {
if (!exercise.questionData) return null;

View File

@@ -1,17 +1,17 @@
<template>
<div class="vocab-new-language-view">
<section class="vocab-new-language-hero surface-card">
<span class="vocab-new-language-hero__eyebrow">Vokabeltrainer</span>
<span class="vocab-new-language-hero__eyebrow">{{ $t('socialnetwork.vocab.newLanguageHeroEyebrow') }}</span>
<h2>{{ $t('socialnetwork.vocab.newLanguageTitle') }}</h2>
<p>Neue Sprache anlegen, Freigabecode erzeugen und direkt in die Bearbeitung wechseln.</p>
<p>{{ $t('socialnetwork.vocab.newLanguageHeroIntro') }}</p>
</section>
<section class="box surface-card">
<label class="label form-field">
<span>{{ $t('socialnetwork.vocab.languageName') }}</span>
<input v-model="name" type="text" :class="{ 'field-error': nameTouched && !canSave }" />
<span class="form-hint">Ein kurzer, klarer Sprachname reicht für den Start.</span>
<span v-if="nameTouched && !canSave" class="form-error">Der Name sollte mindestens 2 Zeichen haben.</span>
<span class="form-hint">{{ $t('socialnetwork.vocab.newLanguageNameHint') }}</span>
<span v-if="nameTouched && !canSave" class="form-error">{{ $t('socialnetwork.vocab.newLanguageNameValidation') }}</span>
</label>
<div class="actions form-actions-row">

View File

@@ -1,7 +1,7 @@
<template>
<div class="vocab-subscribe-view">
<section class="vocab-subscribe-hero surface-card">
<span class="vocab-subscribe-hero__eyebrow">Vokabeltrainer</span>
<span class="vocab-subscribe-hero__eyebrow">{{ $t('socialnetwork.vocab.subscribeHeroEyebrow') }}</span>
<h2>{{ $t('socialnetwork.vocab.subscribeTitle') }}</h2>
<p>{{ $t('socialnetwork.vocab.subscribeHint') }}</p>
</section>

View File

@@ -2,7 +2,7 @@
<div class="vocab-view">
<section class="vocab-hero surface-card">
<div>
<span class="vocab-kicker">Sprachenlernen</span>
<span class="vocab-kicker">{{ $t('socialnetwork.vocab.heroEyebrow') }}</span>
<h2>{{ $t('socialnetwork.vocab.title') }}</h2>
<p>{{ $t('socialnetwork.vocab.description') }}</p>
</div>
@@ -14,33 +14,33 @@
<section class="vocab-summary-grid">
<article class="summary-card surface-card">
<span class="summary-card__label">Sprachen gesamt</span>
<span class="summary-card__label">{{ $t('socialnetwork.vocab.summaryTotalLabel') }}</span>
<strong>{{ languages.length }}</strong>
<p>Alle aktiven Sprachbereiche, in denen du Inhalte nutzt oder verwaltest.</p>
<p>{{ $t('socialnetwork.vocab.summaryTotalIntro') }}</p>
</article>
<article class="summary-card surface-card">
<span class="summary-card__label">Eigene Bereiche</span>
<span class="summary-card__label">{{ $t('socialnetwork.vocab.summaryOwnedLabel') }}</span>
<strong>{{ ownedLanguages.length }}</strong>
<p>Hier legst du Inhalte, Kapitel und Lernmaterial aktiv selbst an.</p>
<p>{{ $t('socialnetwork.vocab.summaryOwnedIntro') }}</p>
</article>
<article class="summary-card surface-card">
<span class="summary-card__label">Abonniert</span>
<span class="summary-card__label">{{ $t('socialnetwork.vocab.summarySubscribedLabel') }}</span>
<strong>{{ subscribedLanguages.length }}</strong>
<p>Diese Bereiche sind eher für Lernen und Fortschritt statt Verwaltung gedacht.</p>
<p>{{ $t('socialnetwork.vocab.summarySubscribedIntro') }}</p>
</article>
</section>
<section class="vocab-task-grid">
<article class="task-card surface-card">
<span class="task-card__eyebrow">Schnellstart</span>
<h3>Neue Sprache anlegen</h3>
<p>Der beste Einstieg, wenn du Inhalte selbst strukturieren und pflegen willst.</p>
<span class="task-card__eyebrow">{{ $t('socialnetwork.vocab.taskCreateEyebrow') }}</span>
<h3>{{ $t('socialnetwork.vocab.taskCreateTitle') }}</h3>
<p>{{ $t('socialnetwork.vocab.taskCreateIntro') }}</p>
<button type="button" @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
</article>
<article class="task-card surface-card">
<span class="task-card__eyebrow">Weiterlernen</span>
<h3>Kurse und Kapitel öffnen</h3>
<p>Springe direkt in bestehende Lernpfade und arbeite mit vorhandenen Kursen weiter.</p>
<span class="task-card__eyebrow">{{ $t('socialnetwork.vocab.taskContinueEyebrow') }}</span>
<h3>{{ $t('socialnetwork.vocab.taskContinueTitle') }}</h3>
<p>{{ $t('socialnetwork.vocab.taskContinueIntro') }}</p>
<button type="button" class="button-secondary" @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
</article>
</section>
@@ -54,8 +54,8 @@
<section class="language-section">
<div class="language-section__header">
<div>
<h3>Eigene Sprachen</h3>
<p>Direkter Einstieg in Bearbeitung, Kapitel und Kursverwaltung.</p>
<h3>{{ $t('socialnetwork.vocab.ownedSectionTitle') }}</h3>
<p>{{ $t('socialnetwork.vocab.ownedSectionIntro') }}</p>
</div>
<span class="language-section__count">{{ ownedLanguages.length }}</span>
</div>
@@ -64,20 +64,20 @@
<button type="button" class="language-card__main" @click="openLanguage(l.id)">
<div class="language-card__info">
<span class="langname">{{ l.name }}</span>
<span class="language-card__hint">Verwalten und Inhalte pflegen</span>
<span class="language-card__hint">{{ $t('socialnetwork.vocab.ownedHint') }}</span>
</div>
<span class="role">{{ $t('socialnetwork.vocab.owner') }}</span>
</button>
</li>
</ul>
<p v-else class="language-empty">Noch keine eigenen Sprachbereiche vorhanden.</p>
<p v-else class="language-empty">{{ $t('socialnetwork.vocab.ownedEmpty') }}</p>
</section>
<section class="language-section">
<div class="language-section__header">
<div>
<h3>Abonnierte Sprachen</h3>
<p>Gut für schnellen Wiedereinstieg ins Lernen ohne Verwaltungsaufwand.</p>
<h3>{{ $t('socialnetwork.vocab.subscribedSectionTitle') }}</h3>
<p>{{ $t('socialnetwork.vocab.subscribedSectionIntro') }}</p>
</div>
<span class="language-section__count">{{ subscribedLanguages.length }}</span>
</div>
@@ -86,13 +86,13 @@
<button type="button" class="language-card__main" @click="openLanguage(l.id)">
<div class="language-card__info">
<span class="langname">{{ l.name }}</span>
<span class="language-card__hint">Lernen, üben und Fortschritt ansehen</span>
<span class="language-card__hint">{{ $t('socialnetwork.vocab.subscribedHint') }}</span>
</div>
<span class="role">{{ $t('socialnetwork.vocab.subscribed') }}</span>
</button>
</li>
</ul>
<p v-else class="language-empty">Keine abonnierten Sprachen vorhanden.</p>
<p v-else class="language-empty">{{ $t('socialnetwork.vocab.subscribedEmpty') }}</p>
</section>
</div>
</section>