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

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>