Add language assistant settings and related features: Introduce new routes and controller methods for managing language assistant settings, including retrieval and saving of LLM configurations. Update navigation structure to include language assistant options. Enhance vocab course model to support additional learning attributes such as learning goals and core patterns. Update SQL scripts to reflect new database schema changes for vocab courses. Improve localization for language assistant settings in German and English.

This commit is contained in:
Torsten Schulz (local)
2026-03-25 15:53:49 +01:00
parent 8af726c65a
commit d50d3c4016
40 changed files with 3145 additions and 56 deletions

View File

@@ -65,6 +65,7 @@ const TITLE_MAP = {
'Sexuality settings': 'Sexualität',
'Flirt settings': 'Flirt',
'Account settings': 'Account',
'Language assistant settings': 'Sprachassistent',
Interests: 'Interessen',
AdminInterests: 'Interessenverwaltung',
AdminUsers: 'Benutzer',

View File

@@ -61,7 +61,8 @@
"flirt": "Flirt",
"interests": "Interessen",
"notifications": "Benachrichtigungen",
"sexuality": "Sexualität"
"sexuality": "Sexualität",
"languageAssistant": "Sprachassistent"
},
"m-administration": {
"contactrequests": "Kontaktanfragen",

View File

@@ -150,6 +150,27 @@
"changeaction": "Benutzerdaten ändern",
"oldpassword": "Altes Passwort (benötigt)"
},
"languageAssistant": {
"eyebrow": "Einstellungen",
"title": "Sprachassistent & KI",
"intro": "Hier kannst du einen eigenen API-Zugang hinterlegen (z. B. OpenAI), den die Plattform für Sprachkurs-Funktionen nutzen kann. Der Schlüssel wird serverseitig verschlüsselt gespeichert; du benötigst ein Konto beim jeweiligen Anbieter.",
"linkSignup": "Konto bei OpenAI anlegen (neues Fenster)",
"linkApiKeys": "API-Keys bei OpenAI verwalten (neues Fenster)",
"enabled": "Nutzung für Sprachfunktionen erlauben",
"baseUrl": "API-Basis-URL (optional)",
"baseUrlPlaceholder": "Leer = Standard (OpenAI). Für Ollama z. B. http://127.0.0.1:11434/v1",
"model": "Modellname",
"apiKey": "API-Schlüssel",
"apiKeyHint": "Leer lassen, um den gespeicherten Schlüssel beizubehalten.",
"apiKeyPlaceholderNew": "Neuen Schlüssel einfügen",
"apiKeyPlaceholderHasKey": "Gespeichert endet auf …{last4} — leer lassen behält den Schlüssel",
"apiKeyPlaceholderClear": "Speicher wird geleert, wenn du unten „Schlüssel löschen“ speicherst",
"clearKey": "Gespeicherten API-Schlüssel entfernen",
"save": "Speichern",
"saved": "Einstellungen gespeichert.",
"saveError": "Speichern fehlgeschlagen.",
"confirmClear": "API-Schlüssel wirklich löschen?"
},
"interests": {
"title": "Interessen",
"new": "Neues Interesse",

View File

@@ -370,9 +370,16 @@
"learn": "Lernen",
"exercises": "Kapitel-Prüfung",
"learnVocabulary": "Vokabeln lernen",
"lessonOverviewText": "Diese Lektion verbindet Vokabeln, Muster, kurze Grammatikimpulse und aktive Sprachpraxis.",
"lessonDescription": "Lektions-Beschreibung",
"culturalNotes": "Kulturelle Notizen",
"grammarExplanations": "Grammatik-Erklärungen",
"grammarImpulse": "Grammatik-Impuls",
"learningGoals": "Lernziele",
"corePatterns": "Kernmuster",
"speakingTasks": "Sprechaufträge",
"speakingPrompt": "Sprechauftrag",
"practicalTasks": "Praxisaufgaben",
"importantVocab": "Wichtige Begriffe",
"vocabInfoText": "Diese Begriffe werden in der Prüfung verwendet. Lerne sie hier passiv, bevor du zur Kapitel-Prüfung wechselst.",
"noVocabInfo": "Lies die Beschreibung oben und die Erklärungen in der Prüfung, um die wichtigsten Begriffe zu lernen.",
@@ -393,12 +400,31 @@
"goToNextLesson": "Zur nächsten Lektion wechseln?",
"allLessonsCompleted": "Alle Lektionen abgeschlossen!",
"startExercises": "Zur Kapitel-Prüfung",
"lessonTypeLabel": "Lektionstyp",
"recommendedDuration": "Empfohlene Dauer",
"exerciseLoad": "Übungsmenge",
"exercisesShort": "Übungen",
"durationFlexible": "Flexibel",
"durationMinutes": "{minutes} Minuten",
"lessonTypeVocab": "Wortschatz",
"lessonTypeGrammar": "Grammatik",
"lessonTypeConversation": "Gespräch",
"lessonTypeCulture": "Kultur",
"lessonTypeReview": "Wiederholung",
"correctAnswer": "Richtige Antwort",
"alternatives": "Alternative Antworten",
"notStarted": "Nicht begonnen",
"continueCurrentLesson": "Zur aktuellen Lektion",
"previousLessonRequired": "Bitte schließe zuerst die vorherige Lektion ab",
"lessonNumberShort": "#",
"buildSentencePlaceholder": "Baue hier deinen Satz",
"completeDialogPlaceholder": "Ergänze die fehlende Dialogzeile",
"situationalResponsePlaceholder": "Formuliere deine Antwort auf die Situation",
"patternDrillPlaceholder": "Formuliere einen passenden Satz mit dem Muster",
"modelSentence": "Modellsatz",
"modelDialogLine": "Mögliche Dialogzeile",
"modelResponse": "Mögliche Antwort",
"patternPrompt": "Muster",
"readingAloudInstruction": "Lies den Text laut vor. Klicke auf 'Aufnahme starten' und beginne zu sprechen.",
"speakingFromMemoryInstruction": "Sprich frei aus dem Kopf. Verwende die angezeigten Schlüsselwörter.",
"startRecording": "Aufnahme starten",
@@ -415,4 +441,4 @@
}
}
}
}
}

View File

@@ -61,7 +61,8 @@
"flirt": "Flirt",
"interests": "Interests",
"notifications": "Notifications",
"sexuality": "Sexuality"
"sexuality": "Sexuality",
"languageAssistant": "Language assistant"
},
"m-administration": {
"contactrequests": "Contact requests",

View File

@@ -150,6 +150,27 @@
"changeaction": "Change User Data",
"oldpassword": "Old Password (required)"
},
"languageAssistant": {
"eyebrow": "Settings",
"title": "Language assistant & AI",
"intro": "Store your own API access (e.g. OpenAI) for language-course features. The key is encrypted on the server. You need an account with the provider.",
"linkSignup": "Create an OpenAI account (new tab)",
"linkApiKeys": "Manage OpenAI API keys (new tab)",
"enabled": "Allow use for language features",
"baseUrl": "API base URL (optional)",
"baseUrlPlaceholder": "Empty = default (OpenAI). For Ollama e.g. http://127.0.0.1:11434/v1",
"model": "Model name",
"apiKey": "API key",
"apiKeyHint": "Leave empty to keep the stored key.",
"apiKeyPlaceholderNew": "Paste new key",
"apiKeyPlaceholderHasKey": "Saved key ends with …{last4} — leave empty to keep",
"apiKeyPlaceholderClear": "Storage will be cleared when you save with “Remove key” below",
"clearKey": "Remove stored API key",
"save": "Save",
"saved": "Settings saved.",
"saveError": "Could not save.",
"confirmClear": "Really delete the API key?"
},
"interests": {
"title": "Interests",
"new": "New Interest",

View File

@@ -370,9 +370,16 @@
"learn": "Learn",
"exercises": "Chapter Test",
"learnVocabulary": "Learn Vocabulary",
"lessonOverviewText": "This lesson combines vocabulary, patterns, short grammar impulses, and active speaking practice.",
"lessonDescription": "Lesson Description",
"culturalNotes": "Cultural Notes",
"grammarExplanations": "Grammar Explanations",
"grammarImpulse": "Grammar Focus",
"learningGoals": "Learning Goals",
"corePatterns": "Core Patterns",
"speakingTasks": "Speaking Tasks",
"speakingPrompt": "Speaking Prompt",
"practicalTasks": "Practical Tasks",
"importantVocab": "Important Vocabulary",
"vocabInfoText": "These terms are used in the test. Learn them here passively before switching to the chapter test.",
"noVocabInfo": "Read the description above and the explanations in the test to learn the most important terms.",
@@ -393,12 +400,31 @@
"goToNextLesson": "Go to next lesson?",
"allLessonsCompleted": "All lessons completed!",
"startExercises": "Start Chapter Test",
"lessonTypeLabel": "Lesson Type",
"recommendedDuration": "Recommended Duration",
"exerciseLoad": "Exercise Load",
"exercisesShort": "exercises",
"durationFlexible": "Flexible",
"durationMinutes": "{minutes} minutes",
"lessonTypeVocab": "Vocabulary",
"lessonTypeGrammar": "Grammar",
"lessonTypeConversation": "Conversation",
"lessonTypeCulture": "Culture",
"lessonTypeReview": "Review",
"correctAnswer": "Correct Answer",
"alternatives": "Alternative Answers",
"notStarted": "Not Started",
"continueCurrentLesson": "Continue Current Lesson",
"previousLessonRequired": "Please complete the previous lesson first",
"lessonNumberShort": "#",
"buildSentencePlaceholder": "Build your sentence here",
"completeDialogPlaceholder": "Complete the missing dialog line",
"situationalResponsePlaceholder": "Write your response to the situation",
"patternDrillPlaceholder": "Create a fitting sentence with the pattern",
"modelSentence": "Model sentence",
"modelDialogLine": "Possible dialog line",
"modelResponse": "Possible response",
"patternPrompt": "Pattern",
"readingAloudInstruction": "Read the text aloud. Click 'Start Recording' and begin speaking.",
"speakingFromMemoryInstruction": "Speak freely from memory. Use the displayed keywords.",
"startRecording": "Start Recording",
@@ -415,4 +441,4 @@
}
}
}
}
}

View File

@@ -61,7 +61,8 @@
"flirt": "Flirt",
"interests": "Interessen",
"notifications": "Notificaciones",
"sexuality": "Sexualidad"
"sexuality": "Sexualidad",
"languageAssistant": "Asistente de idiomas"
},
"m-administration": {
"contactrequests": "Solicitudes de contacto",

View File

@@ -150,6 +150,27 @@
"changeaction": "Actualizar datos de usuario",
"oldpassword": "Contraseña anterior (obligatoria)"
},
"languageAssistant": {
"eyebrow": "Ajustes",
"title": "Asistente de idiomas e IA",
"intro": "Aquí puedes guardar tu propio acceso API (p. ej. OpenAI) para funciones del curso de idiomas. La clave se guarda cifrada en el servidor; necesitas una cuenta en el proveedor.",
"linkSignup": "Crear cuenta en OpenAI (nueva pestaña)",
"linkApiKeys": "Gestionar claves API de OpenAI (nueva pestaña)",
"enabled": "Permitir uso para funciones de idioma",
"baseUrl": "URL base de la API (opcional)",
"baseUrlPlaceholder": "Vacío = predeterminado (OpenAI). Para Ollama p. ej. http://127.0.0.1:11434/v1",
"model": "Nombre del modelo",
"apiKey": "Clave API",
"apiKeyHint": "Déjalo vacío para conservar la clave guardada.",
"apiKeyPlaceholderNew": "Pegar nueva clave",
"apiKeyPlaceholderHasKey": "La clave guardada termina en …{last4} — vacío = conservar",
"apiKeyPlaceholderClear": "Se borrará al guardar con «Eliminar clave» abajo",
"clearKey": "Eliminar clave API guardada",
"save": "Guardar",
"saved": "Ajustes guardados.",
"saveError": "No se pudo guardar.",
"confirmClear": "¿Eliminar realmente la clave API?"
},
"interests": {
"title": "Intereses",
"new": "Nuevo interés",

View File

@@ -367,9 +367,16 @@
"learn": "Aprender",
"exercises": "Prueba del capítulo",
"learnVocabulary": "Aprender vocabulario",
"lessonOverviewText": "Esta lección combina vocabulario, patrones, pequeñas explicaciones gramaticales y práctica activa.",
"lessonDescription": "Descripción de la lección",
"culturalNotes": "Notas culturales",
"grammarExplanations": "Explicaciones gramaticales",
"grammarImpulse": "Impulso gramatical",
"learningGoals": "Objetivos",
"corePatterns": "Patrones básicos",
"speakingTasks": "Tareas orales",
"speakingPrompt": "Tarea oral",
"practicalTasks": "Tareas prácticas",
"importantVocab": "Términos importantes",
"vocabInfoText": "Estos términos se usarán en la prueba. Apréndelos aquí antes de pasar a la prueba del capítulo.",
"noVocabInfo": "Lee la descripción de arriba y las explicaciones de la prueba para aprender los términos más importantes.",
@@ -390,12 +397,31 @@
"goToNextLesson": "¿Pasar a la siguiente lección?",
"allLessonsCompleted": "¡Todas las lecciones completadas!",
"startExercises": "Ir a la prueba del capítulo",
"lessonTypeLabel": "Tipo de lección",
"recommendedDuration": "Duración recomendada",
"exerciseLoad": "Carga de ejercicios",
"exercisesShort": "ejercicios",
"durationFlexible": "Flexible",
"durationMinutes": "{minutes} minutos",
"lessonTypeVocab": "Vocabulario",
"lessonTypeGrammar": "Gramática",
"lessonTypeConversation": "Conversación",
"lessonTypeCulture": "Cultura",
"lessonTypeReview": "Repaso",
"correctAnswer": "Respuesta correcta",
"alternatives": "Respuestas alternativas",
"notStarted": "No empezado",
"continueCurrentLesson": "Continuar lección actual",
"previousLessonRequired": "Primero completa la lección anterior",
"lessonNumberShort": "#",
"buildSentencePlaceholder": "Construye aquí tu frase",
"completeDialogPlaceholder": "Completa la línea que falta en el diálogo",
"situationalResponsePlaceholder": "Formula tu respuesta a la situación",
"patternDrillPlaceholder": "Crea una frase adecuada con el patrón",
"modelSentence": "Frase modelo",
"modelDialogLine": "Línea posible del diálogo",
"modelResponse": "Respuesta posible",
"patternPrompt": "Patrón",
"readingAloudInstruction": "Lee el texto en voz alta. Haz clic en 'Iniciar grabación' y comienza a hablar.",
"speakingFromMemoryInstruction": "Habla de memoria. Usa las palabras clave mostradas.",
"startRecording": "Iniciar grabación",

View File

@@ -4,6 +4,7 @@ const FlirtSettingsView = () => import('../views/settings/FlirtView.vue');
const SexualitySettingsView = () => import('../views/settings/SexualityView.vue');
const AccountSettingsView = () => import('../views/settings/AccountView.vue');
const InterestsView = () => import('../views/settings/InterestsView.vue');
const LanguageAssistantView = () => import('../views/settings/LanguageAssistantView.vue');
const settingsRoutes = [
{
@@ -42,6 +43,12 @@ const settingsRoutes = [
component: InterestsView,
meta: { requiresAuth: true }
},
{
path: '/settings/language-assistant',
name: 'Language assistant settings',
component: LanguageAssistantView,
meta: { requiresAuth: true }
},
];
export default settingsRoutes;

View File

@@ -0,0 +1,224 @@
<template>
<div class="language-assistant-settings">
<section class="language-assistant-settings__hero surface-card">
<span class="language-assistant-settings__eyebrow">{{ $t('settings.languageAssistant.eyebrow') }}</span>
<h2>{{ $t('settings.languageAssistant.title') }}</h2>
<p class="language-assistant-settings__intro">{{ $t('settings.languageAssistant.intro') }}</p>
<ul class="language-assistant-settings__links">
<li>
<a href="https://platform.openai.com/signup" target="_blank" rel="noopener noreferrer">
{{ $t('settings.languageAssistant.linkSignup') }}
</a>
</li>
<li>
<a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer">
{{ $t('settings.languageAssistant.linkApiKeys') }}
</a>
</li>
</ul>
</section>
<section v-if="loadError" class="surface-card language-assistant-settings__panel">
<p class="form-error">{{ loadError }}</p>
</section>
<section v-else class="language-assistant-settings__panel surface-card">
<label class="language-assistant-settings__toggle">
<input type="checkbox" v-model="form.enabled" />
<span>{{ $t('settings.languageAssistant.enabled') }}</span>
</label>
<div class="language-assistant-settings__grid">
<label class="language-assistant-settings__field">
<span>{{ $t('settings.languageAssistant.baseUrl') }}</span>
<input
v-model="form.baseUrl"
type="url"
autocomplete="off"
:placeholder="$t('settings.languageAssistant.baseUrlPlaceholder')"
/>
</label>
<label class="language-assistant-settings__field">
<span>{{ $t('settings.languageAssistant.model') }}</span>
<input v-model="form.model" type="text" autocomplete="off" placeholder="gpt-4o-mini" />
</label>
<label class="language-assistant-settings__field language-assistant-settings__field--full">
<span>{{ $t('settings.languageAssistant.apiKey') }}</span>
<input
v-model="form.apiKey"
type="password"
autocomplete="new-password"
:placeholder="apiKeyPlaceholder"
/>
<span class="language-assistant-settings__hint">{{ $t('settings.languageAssistant.apiKeyHint') }}</span>
</label>
</div>
<label class="language-assistant-settings__toggle">
<input type="checkbox" v-model="form.clearKey" />
<span>{{ $t('settings.languageAssistant.clearKey') }}</span>
</label>
<div class="language-assistant-settings__actions">
<button type="button" :disabled="saving" @click="save">{{ $t('settings.languageAssistant.save') }}</button>
</div>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'LanguageAssistantSettingsView',
data() {
return {
form: {
enabled: true,
baseUrl: '',
model: 'gpt-4o-mini',
apiKey: '',
clearKey: false
},
hasKey: false,
keyLast4: null,
saving: false,
loadError: null
};
},
computed: {
...mapGetters(['user']),
apiKeyPlaceholder() {
if (this.form.clearKey) {
return this.$t('settings.languageAssistant.apiKeyPlaceholderClear');
}
if (this.hasKey) {
return this.$t('settings.languageAssistant.apiKeyPlaceholderHasKey', {
last4: this.keyLast4 || '••••'
});
}
return this.$t('settings.languageAssistant.apiKeyPlaceholderNew');
}
},
async mounted() {
await this.load();
},
methods: {
async load() {
this.loadError = null;
try {
const { data } = await apiClient.get('/api/settings/llm');
this.form.enabled = data.enabled !== false;
this.form.baseUrl = data.baseUrl || '';
this.form.model = data.model || 'gpt-4o-mini';
this.hasKey = data.hasKey;
this.keyLast4 = data.keyLast4;
this.form.apiKey = '';
this.form.clearKey = false;
} catch (e) {
this.loadError = e.response?.data?.error || e.message || 'Error';
}
},
async save() {
if (this.form.clearKey && !window.confirm(this.$t('settings.languageAssistant.confirmClear'))) {
return;
}
this.saving = true;
try {
await apiClient.post('/api/settings/llm', {
enabled: this.form.enabled,
baseUrl: this.form.baseUrl,
model: this.form.model,
apiKey: this.form.apiKey,
clearKey: this.form.clearKey
});
showSuccess(this, this.$t('settings.languageAssistant.saved'));
this.form.apiKey = '';
this.form.clearKey = false;
await this.load();
} catch (e) {
showApiError(this, e, () => showError(this, this.$t('settings.languageAssistant.saveError')));
} finally {
this.saving = false;
}
}
}
};
</script>
<style scoped>
.language-assistant-settings__hero {
padding: 1.25rem 1.5rem;
margin-bottom: 1rem;
}
.language-assistant-settings__eyebrow {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.75;
margin-bottom: 0.35rem;
}
.language-assistant-settings__intro {
margin: 0.5rem 0 1rem;
line-height: 1.5;
}
.language-assistant-settings__links {
margin: 0;
padding-left: 1.25rem;
}
.language-assistant-settings__links a {
color: var(--color-link, #3b82f6);
}
.language-assistant-settings__panel {
padding: 1.25rem 1.5rem;
}
.language-assistant-settings__grid {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
@media (min-width: 640px) {
.language-assistant-settings__grid {
grid-template-columns: 1fr 1fr;
}
.language-assistant-settings__field--full {
grid-column: 1 / -1;
}
}
.language-assistant-settings__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.language-assistant-settings__field span:first-child {
font-weight: 600;
font-size: 0.9rem;
}
.language-assistant-settings__field input {
padding: 0.5rem 0.65rem;
border-radius: 6px;
border: 1px solid var(--color-border, #ccc);
background: var(--color-input-bg, #fff);
color: inherit;
}
.language-assistant-settings__hint {
font-size: 0.8rem;
opacity: 0.8;
}
.language-assistant-settings__toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
cursor: pointer;
}
.language-assistant-settings__actions {
margin-top: 1.25rem;
}
.form-error {
color: #c62828;
}
</style>

View File

@@ -30,26 +30,80 @@
<!-- Lernen-Tab -->
<div v-if="activeTab === 'learn'" class="learn-section">
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<!-- Lektions-Beschreibung -->
<div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p>
<div class="lesson-overview-card">
<div>
<h3>{{ $t('socialnetwork.vocab.courses.learnVocabulary') }}</h3>
<p class="lesson-overview-text">
{{ $t('socialnetwork.vocab.courses.lessonOverviewText') }}
</p>
</div>
<div class="lesson-meta-grid">
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.lessonTypeLabel') }}</span>
<strong>{{ getLessonTypeLabel(lesson.lessonType) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.recommendedDuration') }}</span>
<strong>{{ formatTargetMinutes(lesson.targetMinutes) }}</strong>
</div>
<div class="lesson-meta-item">
<span class="lesson-meta-label">{{ $t('socialnetwork.vocab.courses.exerciseLoad') }}</span>
<strong>{{ effectiveExercises?.length || 0 }} {{ $t('socialnetwork.vocab.courses.exercisesShort') }}</strong>
</div>
</div>
</div>
<!-- Kulturelle Notizen -->
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
<div class="learn-grid">
<div v-if="lesson && lesson.description" class="lesson-description-box">
<h4>{{ $t('socialnetwork.vocab.courses.lessonDescription') }}</h4>
<p>{{ lesson.description }}</p>
</div>
<!-- Grammatik-Erklärungen -->
<div v-if="grammarExplanations && grammarExplanations.length > 0" class="grammar-explanations">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in grammarExplanations" :key="index" class="grammar-explanation-item">
<strong>{{ explanation.title }}</strong>
<p>{{ explanation.text }}</p>
<div v-if="lessonDidactics.learningGoals.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.learningGoals') }}</h4>
<ul class="didactic-list">
<li v-for="(goal, index) in lessonDidactics.learningGoals" :key="'goal-' + index">{{ goal }}</li>
</ul>
</div>
<div v-if="lessonDidactics.corePatterns.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.corePatterns') }}</h4>
<div class="pattern-list">
<div v-for="(pattern, index) in lessonDidactics.corePatterns" :key="'pattern-' + index" class="pattern-item">
{{ pattern }}
</div>
</div>
</div>
<div v-if="lessonDidactics.grammarFocus.length > 0" class="grammar-explanations didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.grammarExplanations') }}</h4>
<div v-for="(explanation, index) in lessonDidactics.grammarFocus" :key="'grammar-' + index" class="grammar-explanation-item">
<strong>{{ explanation.title || $t('socialnetwork.vocab.courses.grammarImpulse') }}</strong>
<p>{{ explanation.text }}</p>
<p v-if="explanation.example" class="grammar-example">{{ explanation.example }}</p>
</div>
</div>
<div v-if="lessonDidactics.speakingPrompts.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.speakingTasks') }}</h4>
<div v-for="(prompt, index) in lessonDidactics.speakingPrompts" :key="'speaking-' + index" class="speaking-prompt-item">
<strong>{{ prompt.title || $t('socialnetwork.vocab.courses.speakingPrompt') }}</strong>
<p>{{ prompt.prompt }}</p>
<p v-if="prompt.cue" class="speaking-cue">{{ prompt.cue }}</p>
</div>
</div>
<div v-if="lessonDidactics.practicalTasks.length > 0" class="didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.practicalTasks') }}</h4>
<div v-for="(task, index) in lessonDidactics.practicalTasks" :key="'task-' + index" class="practical-task-item">
<strong>{{ task.title }}</strong>
<p>{{ task.text }}</p>
</div>
</div>
<div v-if="lesson && lesson.culturalNotes" class="cultural-notes didactic-card">
<h4>{{ $t('socialnetwork.vocab.courses.culturalNotes') }}</h4>
<p>{{ lesson.culturalNotes }}</p>
</div>
</div>
@@ -253,6 +307,94 @@
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'sentence_building'" class="sentence-building-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.tokens?.length" class="token-list">
<span v-for="(token, index) in getQuestionData(exercise).tokens" :key="index" class="token-chip">{{ token }}</span>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.buildSentencePlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelSentence') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'dialog_completion'" class="dialog-completion-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<div v-if="getQuestionData(exercise)?.dialog" class="dialog-snippet">
<p v-for="(line, index) in getQuestionData(exercise).dialog" :key="index">{{ line }}</p>
</div>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.completeDialogPlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelDialogLine') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'situational_response'" class="situational-response-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<textarea
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.situationalResponsePlaceholder')"
class="response-textarea"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelResponse') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].alternatives && exerciseResults[exercise.id].alternatives.length > 0" class="alternatives">
{{ $t('socialnetwork.vocab.courses.keywords') }}: {{ exerciseResults[exercise.id].alternatives.join(', ') }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<div v-else-if="getExerciseType(exercise) === 'pattern_drill'" class="pattern-drill-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
<p v-if="getQuestionData(exercise)?.pattern" class="pattern-drill-hint">
{{ $t('socialnetwork.vocab.courses.patternPrompt') }}: {{ getQuestionData(exercise).pattern }}
</p>
<input
v-model="exerciseAnswers[exercise.id]"
:placeholder="$t('socialnetwork.vocab.courses.patternDrillPlaceholder')"
class="transformation-input"
/>
<button @click="checkAnswer(exercise.id)" :disabled="!exerciseAnswers[exercise.id]">
{{ $t('socialnetwork.vocab.courses.checkAnswer') }}
</button>
<div v-if="exerciseResults[exercise.id]" class="exercise-result" :class="exerciseResults[exercise.id].correct ? 'correct' : 'wrong'">
<strong>{{ exerciseResults[exercise.id].correct ? $t('socialnetwork.vocab.courses.correct') : $t('socialnetwork.vocab.courses.wrong') }}</strong>
<p v-if="exerciseResults[exercise.id].correctAnswer" class="correct-answer">
{{ $t('socialnetwork.vocab.courses.modelPattern') }}: {{ exerciseResults[exercise.id].correctAnswer }}
</p>
<p v-if="exerciseResults[exercise.id].explanation" class="exercise-explanation">{{ exerciseResults[exercise.id].explanation }}</p>
</div>
</div>
<!-- Reading Aloud Übung -->
<div v-else-if="getExerciseType(exercise) === 'reading_aloud'" class="reading-aloud-exercise">
<p class="exercise-question">{{ getQuestionText(exercise) }}</p>
@@ -543,6 +685,15 @@ export default {
console.error('Fehler in importantVocab computed property:', e);
return [];
}
},
lessonDidactics() {
return this.lesson?.didactics || {
learningGoals: [],
corePatterns: [],
grammarFocus: [],
speakingPrompts: [],
practicalTasks: []
};
}
},
watch: {
@@ -787,10 +938,31 @@ export default {
5: 'conjugation',
6: 'declension',
7: 'reading_aloud',
8: 'speaking_from_memory'
8: 'speaking_from_memory',
9: 'dialog_completion',
10: 'situational_response',
11: 'pattern_drill'
};
return typeMap[exercise.exerciseTypeId] || 'unknown';
},
getLessonTypeLabel(lessonType) {
const labels = {
vocab: this.$t('socialnetwork.vocab.courses.lessonTypeVocab'),
grammar: this.$t('socialnetwork.vocab.courses.lessonTypeGrammar'),
conversation: this.$t('socialnetwork.vocab.courses.lessonTypeConversation'),
culture: this.$t('socialnetwork.vocab.courses.lessonTypeCulture'),
review: this.$t('socialnetwork.vocab.courses.lessonTypeReview'),
vocab_review: this.$t('socialnetwork.vocab.courses.lessonTypeReview')
};
return labels[lessonType] || lessonType || this.$t('socialnetwork.vocab.courses.lessonTypeVocab');
},
formatTargetMinutes(targetMinutes) {
const minutes = Number(targetMinutes);
if (!minutes) {
return this.$t('socialnetwork.vocab.courses.durationFlexible');
}
return this.$t('socialnetwork.vocab.courses.durationMinutes', { minutes });
},
getQuestionData(exercise) {
if (!exercise.questionData) return null;
return typeof exercise.questionData === 'string'
@@ -861,9 +1033,11 @@ export default {
} else if (exerciseType === 'multiple_choice') {
// Multiple Choice: Index als Zahl
answer = Number(answer);
} else if (exerciseType === 'transformation') {
} else if (exerciseType === 'transformation' || exerciseType === 'sentence_building' || exerciseType === 'dialog_completion' || exerciseType === 'pattern_drill') {
// Transformation: String
answer = String(answer || '').trim();
} else if (exerciseType === 'situational_response') {
answer = String(answer || '').trim();
} else if (exerciseType === 'reading_aloud' || exerciseType === 'speaking_from_memory') {
// Reading Aloud / Speaking From Memory: Verwende erkannten Text
answer = this.recognizedText[exerciseId] || String(answer || '').trim();
@@ -1453,6 +1627,126 @@ export default {
margin-bottom: 20px;
}
.lesson-overview-card {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 20px;
margin-bottom: 20px;
background: linear-gradient(135deg, #fff8eb 0%, #f7efe2 100%);
border: 1px solid rgba(160, 120, 40, 0.18);
border-radius: 12px;
}
.lesson-overview-text {
margin: 8px 0 0;
color: #5b4b2f;
}
.lesson-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr));
gap: 12px;
min-width: 360px;
}
.lesson-meta-item {
padding: 12px 14px;
background: rgba(255, 255, 255, 0.72);
border-radius: 10px;
}
.lesson-meta-label {
display: block;
margin-bottom: 6px;
font-size: 0.82rem;
color: #7a6848;
}
.learn-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.didactic-card,
.lesson-description-box,
.cultural-notes {
padding: 18px;
background: #f8f9fa;
border-radius: 12px;
border: 1px solid #e7e7e7;
}
.didactic-list {
margin: 0;
padding-left: 20px;
}
.didactic-list li + li {
margin-top: 8px;
}
.pattern-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.pattern-item {
padding: 12px 14px;
border-left: 4px solid #d2831f;
background: #fff;
border-radius: 8px;
}
.grammar-example,
.speaking-cue,
.pattern-drill-hint {
margin-top: 8px;
color: #66553a;
font-style: italic;
}
.speaking-prompt-item + .speaking-prompt-item,
.practical-task-item + .practical-task-item {
margin-top: 14px;
}
.token-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0;
}
.token-chip {
display: inline-flex;
align-items: center;
padding: 6px 12px;
background: #eef3ff;
border: 1px solid #cfdbff;
border-radius: 999px;
font-size: 0.95rem;
}
.dialog-snippet {
margin: 14px 0;
padding: 14px;
background: #fff;
border-radius: 10px;
border: 1px solid #e6e6e6;
}
.response-textarea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 8px;
resize: vertical;
}
.btn-back {
padding: 8px 16px;
border: 1px solid #ddd;
@@ -2023,7 +2317,11 @@ export default {
/* Reading Aloud & Speaking From Memory Styles */
.reading-aloud-exercise,
.speaking-from-memory-exercise {
.speaking-from-memory-exercise,
.sentence-building-exercise,
.dialog-completion-exercise,
.situational-response-exercise,
.pattern-drill-exercise {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
@@ -2229,4 +2527,15 @@ export default {
.dialog-button:hover {
background: #0056b3;
}
@media (max-width: 900px) {
.lesson-overview-card {
flex-direction: column;
}
.lesson-meta-grid {
grid-template-columns: 1fr;
min-width: 0;
}
}
</style>