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:
153
frontend/src/views/social/VocabChapterView.vue
Normal file
153
frontend/src/views/social/VocabChapterView.vue
Normal 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>
|
||||
|
||||
|
||||
149
frontend/src/views/social/VocabLanguageView.vue
Normal file
149
frontend/src/views/social/VocabLanguageView.vue
Normal 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>
|
||||
|
||||
|
||||
106
frontend/src/views/social/VocabNewLanguageView.vue
Normal file
106
frontend/src/views/social/VocabNewLanguageView.vue
Normal 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>
|
||||
|
||||
|
||||
93
frontend/src/views/social/VocabSubscribeView.vue
Normal file
93
frontend/src/views/social/VocabSubscribeView.vue
Normal 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>
|
||||
|
||||
|
||||
86
frontend/src/views/social/VocabTrainerView.vue
Normal file
86
frontend/src/views/social/VocabTrainerView.vue
Normal 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user