Add Vocab Trainer feature with routing, database schema, and translations

- Introduced Vocab Trainer functionality, including new routes for managing languages and chapters.
- Implemented database schema for vocab-related tables to ensure data integrity.
- Updated navigation and UI components to include Vocab Trainer in the social network menu.
- Added translations for Vocab Trainer in both German and English locales, enhancing user accessibility.
This commit is contained in:
Torsten Schulz (local)
2025-12-30 18:34:32 +01:00
parent a09220b881
commit 83597d9e02
24 changed files with 2135 additions and 3 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -27,7 +27,7 @@
:style="`background-image:url('/images/icons/${subitem.icon}')`"
class="submenu-icon"
>&nbsp;</span>
<span>{{ $t(`navigation.m-${key}.${subkey}`) }}</span>
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span
v-if="subkey === 'forum' || subitem.children"
class="subsubmenu"
@@ -62,7 +62,7 @@
:style="`background-image:url('/images/icons/${subsubitem.icon}')`"
class="submenu-icon"
>&nbsp;</span>
<span>{{ $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
<span>{{ subsubitem?.label || $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
</li>
</ul>
</li>

View File

@@ -0,0 +1,504 @@
<template>
<DialogWidget
ref="dialog"
:title="$t('socialnetwork.vocab.practice.title')"
:show-close="true"
:buttons="buttons"
:modal="true"
:isTitleTranslated="false"
width="55em"
height="32em"
name="VocabPracticeDialog"
display="flex"
@close="close"
>
<div class="layout">
<div class="left">
<div class="opts">
<label class="chk">
<input type="checkbox" v-model="allVocabs" @change="reloadPool" />
{{ $t('socialnetwork.vocab.practice.allVocabs') }}
</label>
<label class="chk">
<input type="checkbox" v-model="simpleMode" @change="onSimpleModeChanged" />
{{ $t('socialnetwork.vocab.practice.simple') }}
</label>
</div>
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="pool.length === 0">
{{ $t('socialnetwork.vocab.practice.noPool') }}
</div>
<div v-else>
<div class="prompt">
<div class="dir">{{ directionLabel }}</div>
<div class="word">{{ currentPrompt }}</div>
</div>
<div v-if="answered" class="feedback" :class="{ ok: lastCorrect, bad: !lastCorrect }">
<div v-if="lastCorrect">{{ $t('socialnetwork.vocab.practice.correct') }}</div>
<div v-else>
{{ $t('socialnetwork.vocab.practice.wrong') }}
<div class="answers">
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.acceptable') }}</div>
<ul>
<li v-for="a in acceptableAnswers" :key="a">{{ a }}</li>
</ul>
</div>
</div>
</div>
<div v-if="!answered" class="answerArea">
<div v-if="simpleMode" class="choices">
<button
v-for="opt in choiceOptions"
:key="opt"
class="choiceBtn"
:disabled="locked"
@click="submitChoice(opt)"
>
{{ opt }}
</button>
</div>
<div v-else class="typing">
<input
ref="answerInput"
v-model="typedAnswer"
type="text"
:disabled="locked"
@keydown.enter.prevent="submitTyped"
/>
<button :disabled="locked || typedAnswer.trim().length === 0" @click="submitTyped">
{{ $t('socialnetwork.vocab.practice.check') }}
</button>
</div>
</div>
<div class="controls">
<button v-if="showNextButton" @click="next">
{{ $t('socialnetwork.vocab.practice.next') }}
</button>
<button v-else-if="showSkipButton" @click="skip">
{{ $t('socialnetwork.vocab.practice.skip') }}
</button>
</div>
</div>
</div>
<div class="right">
<div class="stat">
<div class="statTitle">{{ $t('socialnetwork.vocab.practice.stats') }}</div>
<div class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
</div>
<div class="statRow">
<span class="k">{{ $t('socialnetwork.vocab.practice.fail') }}</span>
<span class="v">{{ wrongCount }} ({{ failPercent }}%)</span>
</div>
</div>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabPracticeDialog',
components: { DialogWidget },
data() {
return {
openParams: null, // { languageId, chapterId }
loading: false,
allVocabs: false,
simpleMode: false,
pool: [],
// session stats
correctCount: 0,
wrongCount: 0,
perId: {}, // { [id]: { c, w, streak, lastAsked } }
lastIds: [],
// current question
current: null, // { id, learning, reference }
direction: 'L2R', // L2R: learning->reference, R2L: reference->learning
acceptableAnswers: [],
choiceOptions: [],
typedAnswer: '',
answered: false,
lastCorrect: false,
locked: false,
autoAdvanceTimer: null,
};
},
computed: {
buttons() {
return [{ text: this.$t('message.close'), action: this.close }];
},
totalCount() {
return this.correctCount + this.wrongCount;
},
successPercent() {
if (this.totalCount === 0) return 0;
return Math.round((this.correctCount / this.totalCount) * 100);
},
failPercent() {
if (this.totalCount === 0) return 0;
return Math.round((this.wrongCount / this.totalCount) * 100);
},
currentPrompt() {
if (!this.current) return '';
return this.direction === 'L2R' ? this.current.learning : this.current.reference;
},
directionLabel() {
return this.direction === 'L2R'
? this.$t('socialnetwork.vocab.practice.dirLearningToRef')
: this.$t('socialnetwork.vocab.practice.dirRefToLearning');
},
showNextButton() {
// Nur bei falscher Antwort auf "Weiter" warten
return this.answered && !this.lastCorrect;
},
showSkipButton() {
return !this.answered;
},
},
methods: {
open({ languageId, chapterId }) {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.openParams = { languageId, chapterId };
this.allVocabs = false;
this.simpleMode = false;
this.correctCount = 0;
this.wrongCount = 0;
this.perId = {};
this.lastIds = [];
this.pool = [];
this.locked = false;
this.resetQuestion();
this.$refs.dialog.open();
this.reloadPool();
},
close() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.$refs.dialog.close();
},
normalize(s) {
return String(s || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
},
resetQuestion() {
this.current = null;
this.direction = Math.random() < 0.5 ? 'L2R' : 'R2L';
this.acceptableAnswers = [];
this.choiceOptions = [];
this.typedAnswer = '';
this.answered = false;
this.lastCorrect = false;
this.locked = false;
},
onSimpleModeChanged() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
this.locked = false;
this.answered = false;
this.lastCorrect = false;
this.typedAnswer = '';
if (!this.pool || this.pool.length === 0) return;
// Wenn wir aktuell keine Frage haben, sofort eine neue ziehen.
if (!this.current) {
this.next();
return;
}
// Aktuelle Frage behalten, nur UI/Antwortmodus neu aufbauen
const prompt = this.currentPrompt;
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
if (this.simpleMode) {
this.buildChoices();
} else {
this.choiceOptions = [];
this.$nextTick(() => this.$refs.answerInput?.focus?.());
}
},
async reloadPool() {
if (!this.openParams) return;
this.loading = true;
try {
let res;
if (this.allVocabs) {
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
this.pool = res.data?.vocabs || [];
} else {
res = await apiClient.get(`/api/vocab/chapters/${this.openParams.chapterId}/vocabs`);
this.pool = res.data?.vocabs || [];
}
} catch (e) {
console.error('Reload pool failed:', e);
this.pool = [];
} finally {
this.loading = false;
this.next();
}
},
getAnswersForPrompt(prompt, direction) {
const p = this.normalize(prompt);
const answers = new Set();
for (const item of this.pool) {
const itemPrompt = direction === 'L2R' ? item.learning : item.reference;
if (this.normalize(itemPrompt) === p) {
const a = direction === 'L2R' ? item.reference : item.learning;
answers.add(a);
}
}
return Array.from(answers);
},
computeWeight(item) {
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
let w = 1;
w += st.w * 2.5;
w *= Math.pow(0.7, st.c);
if (st.streak > 0) {
w *= Math.pow(0.8, st.streak);
} else if (st.streak < 0) {
w *= 1 + Math.min(5, Math.abs(st.streak));
}
if (this.lastIds.includes(item.id)) w *= 0.1;
return Math.max(0.05, Math.min(50, w));
},
pickNextItem() {
const items = this.pool;
if (!items || items.length === 0) return null;
const weights = items.map((it) => this.computeWeight(it));
const sum = weights.reduce((a, b) => a + b, 0);
let r = Math.random() * sum;
for (let i = 0; i < items.length; i++) {
r -= weights[i];
if (r <= 0) return items[i];
}
return items[items.length - 1];
},
buildChoices() {
const prompt = this.currentPrompt;
const acceptable = this.getAnswersForPrompt(prompt, this.direction);
this.acceptableAnswers = acceptable;
const options = new Set();
// 1) mindestens eine richtige Übersetzung
options.add(acceptable[0] || (this.direction === 'L2R' ? this.current.reference : this.current.learning));
// 2) weitere Übersetzungen (Mehrdeutigkeiten) fürs gleiche Wort
for (const a of acceptable) {
if (options.size >= 3) break;
options.add(a);
}
// 3) Distraktoren aus anderen Wörtern
const allAnswers = this.pool.map((it) => (this.direction === 'L2R' ? it.reference : it.learning));
for (let i = 0; i < 50 && options.size < 4; i++) {
const cand = allAnswers[Math.floor(Math.random() * allAnswers.length)];
if (!acceptable.map(this.normalize).includes(this.normalize(cand))) {
options.add(cand);
}
}
const arr = Array.from(options);
// shuffle
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
this.choiceOptions = arr;
},
async playSound(ok) {
try {
const audio = new Audio(ok ? '/sounds/success.mp3' : '/sounds/fail.mp3');
await audio.play();
} catch (_) {
// ignore autoplay issues
}
},
markResult(isCorrect) {
this.answered = true;
this.lastCorrect = isCorrect;
if (isCorrect) this.correctCount += 1;
else this.wrongCount += 1;
const id = this.current?.id;
if (!id) return;
const st = this.perId[id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
if (isCorrect) {
st.c += 1;
st.streak = st.streak >= 0 ? st.streak + 1 : 1;
} else {
st.w += 1;
st.streak = st.streak <= 0 ? st.streak - 1 : -1;
}
st.lastAsked = Date.now();
this.perId[id] = st;
this.lastIds.unshift(id);
this.lastIds = this.lastIds.slice(0, 3);
},
submitChoice(opt) {
if (this.locked) return;
const ok = this.acceptableAnswers.map(this.normalize).includes(this.normalize(opt));
this.markResult(ok);
this.playSound(ok);
if (ok) {
// Direkt weiter zur nächsten Frage (kein Klick nötig)
this.locked = true;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
this.next();
}, 350);
}
},
submitTyped() {
if (this.locked) return;
const ans = this.normalize(this.typedAnswer);
const ok = this.acceptableAnswers.map(this.normalize).includes(ans);
this.markResult(ok);
this.playSound(ok);
if (ok) {
this.locked = true;
this.autoAdvanceTimer = setTimeout(() => {
this.autoAdvanceTimer = null;
this.next();
}, 350);
}
},
skip() {
this.next();
},
next() {
if (this.autoAdvanceTimer) {
clearTimeout(this.autoAdvanceTimer);
this.autoAdvanceTimer = null;
}
if (!this.pool || this.pool.length === 0) {
this.resetQuestion();
return;
}
this.resetQuestion();
this.current = this.pickNextItem();
if (!this.current) return;
const prompt = this.currentPrompt;
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
if (this.simpleMode) this.buildChoices();
this.$nextTick(() => {
if (!this.simpleMode) this.$refs.answerInput?.focus?.();
});
},
},
};
</script>
<style scoped>
.layout {
display: flex;
gap: 16px;
height: 100%;
}
.left {
flex: 1;
min-width: 0;
}
.right {
width: 16em;
border-left: 1px solid #ddd;
padding-left: 12px;
}
.opts {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.chk {
display: inline-flex;
gap: 6px;
align-items: center;
}
.prompt {
padding: 10px;
background: #fff;
border: 1px solid #ccc;
margin-bottom: 10px;
}
.dir {
color: #555;
font-size: 0.9em;
}
.word {
font-size: 1.8em;
font-weight: bold;
}
.typing {
display: flex;
gap: 8px;
align-items: center;
}
.typing input {
flex: 1;
padding: 6px;
}
.choices {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.choiceBtn {
padding: 8px;
}
.controls {
margin-top: 12px;
}
.feedback {
padding: 10px;
border: 1px solid #ccc;
margin-bottom: 10px;
}
.feedback.ok {
background: #e8ffe8;
border-color: #7bbe55;
}
.feedback.bad {
background: #ffecec;
border-color: #d33;
}
.answersTitle {
margin-top: 6px;
font-weight: bold;
}
.statTitle {
font-weight: bold;
margin-bottom: 8px;
}
.statRow {
display: flex;
justify-content: space-between;
margin-bottom: 6px;
}
.k {
color: #333;
}
.v {
font-weight: bold;
}
</style>

View File

@@ -26,7 +26,10 @@
}
},
"general": {
"datetimelong": "dd.MM.yyyy HH:mm:ss"
"datetimelong": "dd.MM.yyyy HH:mm:ss",
"loading": "Lädt...",
"back": "Zurück",
"cancel": "Abbrechen"
},
"OK": "Ok",
"Cancel": "Abbrechen",

View File

@@ -20,6 +20,7 @@
"usersearch": "Benutzersuche",
"forum": "Forum",
"gallery": "Galerie",
"vocabtrainer": "Vokabeltrainer",
"blockedUsers": "Blockierte Benutzer",
"oneTimeInvitation": "Einmal-Einladungen",
"diary": "Tagebuch",
@@ -27,6 +28,9 @@
"m-erotic": {
"pictures": "Bilder",
"videos": "Videos"
},
"m-vocabtrainer": {
"newLanguage": "Neue Sprache"
}
},
"m-minigames": {

View File

@@ -249,5 +249,66 @@
"denied": "Du hast die Freundschaftsanfrage abgelehnt.",
"accepted": "Die Freundschaft wurde geschlossen."
}
,
"vocab": {
"title": "Vokabeltrainer",
"description": "Lege Sprachen an (oder abonniere sie) und teile sie mit Freunden.",
"newLanguage": "Neue Sprache",
"newLanguageTitle": "Neue Sprache anlegen",
"languageName": "Name der Sprache",
"create": "Anlegen",
"saving": "Speichere...",
"created": "Sprache wurde angelegt.",
"createdTitle": "Vokabeltrainer",
"createdMessage": "Sprache wurde angelegt. Das Menü wird aktualisiert.",
"createError": "Konnte die Sprache nicht anlegen.",
"openLanguage": "Öffnen",
"none": "Du hast noch keine Sprachen angelegt oder abonniert.",
"owner": "Eigen",
"subscribed": "Abonniert",
"languageTitle": "Vokabeltrainer: {name}",
"notFound": "Sprache nicht gefunden oder kein Zugriff.",
"shareCode": "Teilen-Code",
"shareHint": "Diesen Code kannst du an Freunde weitergeben, damit sie die Sprache abonnieren können.",
"subscribeByCode": "Per Code abonnieren",
"subscribeTitle": "Sprache abonnieren",
"subscribeHint": "Gib den Teilen-Code ein, den du von einem Freund bekommen hast.",
"subscribe": "Abonnieren",
"subscribeSuccess": "Abo erfolgreich. Menü wird aktualisiert.",
"subscribeError": "Abo fehlgeschlagen. Code ungültig oder kein Zugriff.",
"trainerPlaceholder": "Trainer-Funktionen (Vokabeln/Abfragen) kommen als nächster Schritt."
,
"chapters": "Kapitel",
"newChapter": "Neues Kapitel",
"createChapter": "Kapitel anlegen",
"createChapterError": "Konnte Kapitel nicht anlegen.",
"noChapters": "Noch keine Kapitel vorhanden.",
"chapterTitle": "Kapitel: {title}",
"addVocab": "Vokabel hinzufügen",
"learningWord": "Lernsprache",
"referenceWord": "Referenz",
"add": "Hinzufügen",
"addVocabError": "Konnte Vokabel nicht hinzufügen.",
"noVocabs": "In diesem Kapitel sind noch keine Vokabeln."
,
"practice": {
"open": "Üben",
"title": "Vokabeln üben",
"allVocabs": "Alle Vokabeln",
"simple": "Einfaches Üben",
"noPool": "Keine Vokabeln zum Üben vorhanden.",
"dirLearningToRef": "Lernsprache → Referenz",
"dirRefToLearning": "Referenz → Lernsprache",
"check": "Prüfen",
"next": "Weiter",
"skip": "Überspringen",
"correct": "Richtig!",
"wrong": "Falsch.",
"acceptable": "Mögliche richtige Übersetzungen:",
"stats": "Statistik",
"success": "Erfolg",
"fail": "Misserfolg"
}
}
}
}

View File

@@ -6,6 +6,12 @@
"dataPrivacy": {
"title": "Data Privacy Policy"
},
"general": {
"loading": "Loading...",
"back": "Back",
"cancel": "Cancel",
"datetimelong": "dd.MM.yyyy HH:mm:ss"
},
"message": {
"close": "Close"
},

View File

@@ -20,6 +20,7 @@
"usersearch": "User search",
"forum": "Forum",
"gallery": "Gallery",
"vocabtrainer": "Vocabulary trainer",
"blockedUsers": "Blocked users",
"oneTimeInvitation": "One-time invitations",
"diary": "Diary",
@@ -27,6 +28,9 @@
"m-erotic": {
"pictures": "Pictures",
"videos": "Videos"
},
"m-vocabtrainer": {
"newLanguage": "New language"
}
},
"m-minigames": {

View File

@@ -249,5 +249,66 @@
"denied": "You have denied the friendship request.",
"accepted": "The friendship has been established."
}
,
"vocab": {
"title": "Vocabulary trainer",
"description": "Create languages (or subscribe to them) and share them with friends.",
"newLanguage": "New language",
"newLanguageTitle": "Create new language",
"languageName": "Language name",
"create": "Create",
"saving": "Saving...",
"created": "Language created.",
"createdTitle": "Vocabulary trainer",
"createdMessage": "Language created. The menu will refresh.",
"createError": "Could not create language.",
"openLanguage": "Open",
"none": "You have no languages yet (created or subscribed).",
"owner": "Owned",
"subscribed": "Subscribed",
"languageTitle": "Vocabulary trainer: {name}",
"notFound": "Language not found or no access.",
"shareCode": "Share code",
"shareHint": "Send this code to friends so they can subscribe to this language.",
"subscribeByCode": "Subscribe by code",
"subscribeTitle": "Subscribe to language",
"subscribeHint": "Enter a share code you received from a friend.",
"subscribe": "Subscribe",
"subscribeSuccess": "Subscribed. The menu will refresh.",
"subscribeError": "Subscribe failed. Invalid code or no access.",
"trainerPlaceholder": "Trainer features (words/quizzes) will be the next step."
,
"chapters": "Chapters",
"newChapter": "New chapter",
"createChapter": "Create chapter",
"createChapterError": "Could not create chapter.",
"noChapters": "No chapters yet.",
"chapterTitle": "Chapter: {title}",
"addVocab": "Add vocabulary",
"learningWord": "To learn",
"referenceWord": "Reference",
"add": "Add",
"addVocabError": "Could not add vocabulary.",
"noVocabs": "No vocabulary in this chapter yet."
,
"practice": {
"open": "Practice",
"title": "Practice vocabulary",
"allVocabs": "All vocabulary",
"simple": "Simple practice",
"noPool": "No vocabulary to practice.",
"dirLearningToRef": "To learn → Reference",
"dirRefToLearning": "Reference → To learn",
"check": "Check",
"next": "Next",
"skip": "Skip",
"correct": "Correct!",
"wrong": "Wrong.",
"acceptable": "Acceptable answers:",
"stats": "Stats",
"success": "Success",
"fail": "Fail"
}
}
}
}

View File

@@ -5,6 +5,11 @@ import GuestbookView from '../views/social/GuestbookView.vue';
import DiaryView from '../views/social/DiaryView.vue';
import ForumView from '../views/social/ForumView.vue';
import ForumTopicView from '../views/social/ForumTopicView.vue';
import VocabTrainerView from '../views/social/VocabTrainerView.vue';
import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue';
import VocabLanguageView from '../views/social/VocabLanguageView.vue';
import VocabSubscribeView from '../views/social/VocabSubscribeView.vue';
import VocabChapterView from '../views/social/VocabChapterView.vue';
const socialRoutes = [
{
@@ -49,6 +54,36 @@ const socialRoutes = [
component: DiaryView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab',
name: 'VocabTrainer',
component: VocabTrainerView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/new',
name: 'VocabNewLanguage',
component: VocabNewLanguageView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/subscribe',
name: 'VocabSubscribe',
component: VocabSubscribeView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId',
name: 'VocabLanguage',
component: VocabLanguageView,
meta: { requiresAuth: true }
},
{
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
name: 'VocabChapter',
component: VocabChapterView,
meta: { requiresAuth: true }
},
];
export default socialRoutes;

View File

@@ -0,0 +1,153 @@
<template>
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
<div class="box">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<div class="row">
<button @click="back">{{ $t('general.back') }}</button>
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
</div>
<div class="row" v-if="chapter.isOwner">
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
<div class="grid">
<label>
{{ $t('socialnetwork.vocab.learningWord') }}
<input v-model="learning" type="text" />
</label>
<label>
{{ $t('socialnetwork.vocab.referenceWord') }}
<input v-model="reference" type="text" />
</label>
</div>
<button :disabled="saving || !canSave" @click="add">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.add') }}
</button>
</div>
<hr />
<div v-if="vocabs.length === 0">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
<table v-else class="tbl">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
<th>{{ $t('socialnetwork.vocab.referenceWord') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in vocabs" :key="v.id">
<td>{{ v.learning }}</td>
<td>{{ v.reference }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<VocabPracticeDialog ref="practiceDialog" />
</template>
<script>
import apiClient from '@/utils/axios.js';
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
export default {
name: 'VocabChapterView',
components: { VocabPracticeDialog },
data() {
return {
loading: false,
saving: false,
chapter: null,
vocabs: [],
learning: '',
reference: '',
};
},
computed: {
canSave() {
return this.learning.trim().length > 0 && this.reference.trim().length > 0;
},
},
methods: {
back() {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}`);
},
openPractice() {
this.$refs.practiceDialog?.open?.({
languageId: this.$route.params.languageId,
chapterId: this.$route.params.chapterId,
});
},
async load() {
this.loading = true;
try {
const res = await apiClient.get(`/api/vocab/chapters/${this.$route.params.chapterId}/vocabs`);
this.chapter = res.data?.chapter || null;
this.vocabs = res.data?.vocabs || [];
} catch (e) {
console.error('Load chapter vocabs failed:', e);
this.chapter = null;
this.vocabs = [];
} finally {
this.loading = false;
}
},
async add() {
this.saving = true;
try {
await apiClient.post(`/api/vocab/chapters/${this.$route.params.chapterId}/vocabs`, {
learning: this.learning,
reference: this.reference,
});
this.learning = '';
this.reference = '';
await this.load();
} catch (e) {
console.error('Add vocab failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.addVocabError'),
this.$t('error.title')
);
} finally {
this.saving = false;
}
},
},
mounted() {
this.load();
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
display: inline-block;
}
.row {
margin-bottom: 10px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 8px;
}
.tbl {
width: 100%;
border-collapse: collapse;
}
.tbl th,
.tbl td {
border: 1px solid #ccc;
padding: 6px;
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<h2>{{ $t('socialnetwork.vocab.languageTitle', { name: language?.name || '' }) }}</h2>
<div class="box">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!language">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<div class="row">
<strong>{{ $t('socialnetwork.vocab.languageName') }}:</strong>
<span>{{ language.name }}</span>
</div>
<div class="row" v-if="language.isOwner && language.shareCode">
<strong>{{ $t('socialnetwork.vocab.shareCode') }}:</strong>
<code>{{ language.shareCode }}</code>
</div>
<div class="row">
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
</div>
<hr />
<div class="row">
<h3>{{ $t('socialnetwork.vocab.chapters') }}</h3>
</div>
<div class="row" v-if="language.isOwner">
<label>
{{ $t('socialnetwork.vocab.newChapter') }}
<input v-model="newChapterTitle" type="text" />
</label>
<button :disabled="creatingChapter || newChapterTitle.trim().length < 2" @click="createChapter">
{{ creatingChapter ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.createChapter') }}
</button>
</div>
<div v-if="chaptersLoading">{{ $t('general.loading') }}</div>
<div v-else>
<div v-if="chapters.length === 0">{{ $t('socialnetwork.vocab.noChapters') }}</div>
<ul v-else>
<li v-for="c in chapters" :key="c.id">
<span class="click" @click="openChapter(c.id)">
{{ c.title }} <span class="count">({{ c.vocabCount }})</span>
</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabLanguageView',
data() {
return {
loading: false,
language: null,
chaptersLoading: false,
chapters: [],
newChapterTitle: '',
creatingChapter: false,
};
},
methods: {
goSubscribe() {
this.$router.push('/socialnetwork/vocab/subscribe');
},
openChapter(chapterId) {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
},
async load() {
this.loading = true;
try {
const res = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
this.language = res.data;
await this.loadChapters();
} catch (e) {
console.error('Load vocab language failed:', e);
this.language = null;
} finally {
this.loading = false;
}
},
async loadChapters() {
this.chaptersLoading = true;
try {
const res = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}/chapters`);
this.chapters = res.data?.chapters || [];
} catch (e) {
console.error('Load chapters failed:', e);
this.chapters = [];
} finally {
this.chaptersLoading = false;
}
},
async createChapter() {
this.creatingChapter = true;
try {
await apiClient.post(`/api/vocab/languages/${this.$route.params.languageId}/chapters`, {
title: this.newChapterTitle,
});
this.newChapterTitle = '';
await this.loadChapters();
} catch (e) {
console.error('Create chapter failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createChapterError'),
this.$t('error.title')
);
} finally {
this.creatingChapter = false;
}
},
},
watch: {
'$route.params.languageId'() {
this.load();
},
},
mounted() {
this.load();
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.row {
margin-bottom: 8px;
}
.click {
cursor: pointer;
text-decoration: underline;
}
.count {
color: #666;
font-size: 0.9em;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<h2>{{ $t('socialnetwork.vocab.newLanguageTitle') }}</h2>
<div class="box">
<label class="label">
{{ $t('socialnetwork.vocab.languageName') }}
<input v-model="name" type="text" />
</label>
<div class="actions">
<button :disabled="saving || !canSave" @click="create">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.create') }}
</button>
<button :disabled="saving" @click="cancel">{{ $t('Cancel') }}</button>
</div>
<div v-if="created" class="created">
<div><strong>{{ $t('socialnetwork.vocab.created') }}</strong></div>
<div>
{{ $t('socialnetwork.vocab.shareCode') }}:
<code>{{ created.shareCode }}</code>
</div>
<div class="hint">{{ $t('socialnetwork.vocab.shareHint') }}</div>
<button @click="openLanguage(created.id)">{{ $t('socialnetwork.vocab.openLanguage') }}</button>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabNewLanguageView',
data() {
return {
name: '',
saving: false,
created: null,
};
},
computed: {
canSave() {
return this.name.trim().length >= 2;
},
},
methods: {
...mapActions(['loadMenu']),
cancel() {
this.$router.push('/socialnetwork/vocab');
},
openLanguage(id) {
this.$router.push(`/socialnetwork/vocab/${id}`);
},
async create() {
this.saving = true;
try {
const res = await apiClient.post('/api/vocab/languages', { name: this.name });
this.created = res.data;
// Menü sofort lokal aktualisieren (zusätzlich zum serverseitigen reloadmenu event)
try { await this.loadMenu(); } catch (_) {}
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createdMessage'),
this.$t('socialnetwork.vocab.createdTitle')
);
} catch (e) {
console.error('Create vocab language failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createError'),
this.$t('error.title')
);
} finally {
this.saving = false;
}
},
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.label {
display: block;
margin-bottom: 10px;
}
.actions {
display: flex;
gap: 8px;
}
.created {
margin-top: 12px;
padding: 10px;
background: #fff;
border: 1px solid #bbb;
}
.hint {
margin-top: 6px;
color: #555;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<h2>{{ $t('socialnetwork.vocab.subscribeTitle') }}</h2>
<div class="box">
<p>{{ $t('socialnetwork.vocab.subscribeHint') }}</p>
<label class="label">
{{ $t('socialnetwork.vocab.shareCode') }}
<input v-model="shareCode" type="text" />
</label>
<div class="actions">
<button :disabled="saving || !canSave" @click="subscribe">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.subscribe') }}
</button>
<button :disabled="saving" @click="back">{{ $t('general.back') }}</button>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabSubscribeView',
data() {
return {
shareCode: '',
saving: false,
};
},
computed: {
canSave() {
return this.shareCode.trim().length >= 6;
},
},
methods: {
...mapActions(['loadMenu']),
back() {
this.$router.push('/socialnetwork/vocab');
},
async subscribe() {
this.saving = true;
try {
const res = await apiClient.post('/api/vocab/subscribe', { shareCode: this.shareCode });
try { await this.loadMenu(); } catch (_) {}
const langId = res.data?.languageId;
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.subscribeSuccess'),
this.$t('socialnetwork.vocab.subscribeTitle')
);
if (langId) {
this.$router.push(`/socialnetwork/vocab/${langId}`);
}
} catch (e) {
console.error('Subscribe failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.subscribeError'),
this.$t('error.title')
);
} finally {
this.saving = false;
}
},
},
mounted() {
// optional: ?code=... unterstützt
const code = this.$route?.query?.code;
if (typeof code === 'string' && code.trim()) {
this.shareCode = code.trim();
}
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.label {
display: block;
margin-bottom: 10px;
}
.actions {
display: flex;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<h2>{{ $t('socialnetwork.vocab.title') }}</h2>
<div class="box">
<p>{{ $t('socialnetwork.vocab.description') }}</p>
<div class="actions">
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
</div>
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else>
<div v-if="languages.length === 0">
{{ $t('socialnetwork.vocab.none') }}
</div>
<ul v-else>
<li v-for="l in languages" :key="l.id">
<span class="langname" @click="openLanguage(l.id)">{{ l.name }}</span>
<span class="role" v-if="l.isOwner">({{ $t('socialnetwork.vocab.owner') }})</span>
<span class="role" v-else>({{ $t('socialnetwork.vocab.subscribed') }})</span>
</li>
</ul>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabTrainerView',
data() {
return {
loading: false,
languages: [],
};
},
computed: {
...mapGetters(['user']),
},
methods: {
goNewLanguage() {
this.$router.push('/socialnetwork/vocab/new');
},
openLanguage(id) {
this.$router.push(`/socialnetwork/vocab/${id}`);
},
async load() {
this.loading = true;
try {
const res = await apiClient.get('/api/vocab/languages');
this.languages = res.data?.languages || [];
} catch (e) {
console.error('Konnte Vokabel-Sprachen nicht laden:', e);
} finally {
this.loading = false;
}
},
},
mounted() {
this.load();
},
};
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
}
.actions {
margin: 10px 0;
}
.langname {
cursor: pointer;
text-decoration: underline;
}
.role {
margin-left: 6px;
color: #666;
}
</style>