feat(vocab): add language and course dictionary endpoints and UI components
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s

- Implemented `getLanguageDictionary` and `getCourseDictionary` methods in the VocabService to retrieve vocabulary entries filtered by search terms.
- Updated VocabController and vocabRouter to include new routes for accessing language and course dictionaries.
- Enhanced frontend components to navigate to the new dictionary views, including buttons in VocabCourseView and VocabLanguageView.
- Added localization entries for the dictionary feature in multiple languages, ensuring a consistent user experience across the platform.
This commit is contained in:
Torsten Schulz (local)
2026-04-10 13:08:18 +02:00
parent 9582e7b900
commit d17c8a341d
18 changed files with 443 additions and 16 deletions

View File

@@ -16,6 +16,9 @@
<span v-if="course.shareCode && isOwner" class="share-code">
{{ $t('socialnetwork.vocab.courses.shareCode') }}: <code>{{ course.shareCode }}</code>
</span>
<button type="button" class="course-dictionary-link" @click="goCourseDictionary">
{{ $t('socialnetwork.vocab.dictionary.open') }}
</button>
</div>
<section class="surface-card course-assistant">
@@ -691,6 +694,9 @@ export default {
showApiError(this, e, this.$t('socialnetwork.vocab.courses.deleteLessonError'));
}
},
goCourseDictionary() {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/dictionary`);
},
openLesson(lessonId) {
this.$router.push(`/socialnetwork/vocab/courses/${this.courseId}/lessons/${lessonId}`);
},
@@ -826,6 +832,11 @@ export default {
color: #666;
flex-wrap: wrap;
padding: 16px 18px;
align-items: center;
}
.course-dictionary-link {
margin-left: auto;
}
.course-assistant {

View File

@@ -0,0 +1,253 @@
<template>
<div class="vocab-dictionary-view">
<section class="dictionary-hero surface-card">
<div v-if="metaLoading">{{ $t('general.loading') }}</div>
<div v-else-if="!contextTitle">{{ $t('socialnetwork.vocab.dictionary.notFound') }}</div>
<div v-else>
<span class="dictionary-kicker">{{ $t('socialnetwork.vocab.dictionary.kicker') }}</span>
<h2>{{ pageHeading }}</h2>
<p>{{ $t('socialnetwork.vocab.dictionary.intro') }}</p>
</div>
</section>
<div v-if="contextTitle" class="dictionary-toolbar surface-card">
<label class="dictionary-search">
<span class="dictionary-search__label">{{ $t('socialnetwork.vocab.dictionary.searchLabel') }}</span>
<input
v-model="q"
type="search"
autocomplete="off"
:placeholder="$t('socialnetwork.vocab.dictionary.searchPlaceholder')"
@input="scheduleLoad"
/>
</label>
</div>
<div class="dictionary-body surface-card">
<div v-if="listLoading" class="dictionary-state">{{ $t('general.loading') }}</div>
<div v-else-if="error" class="dictionary-state dictionary-state--error">{{ error }}</div>
<div v-else-if="results.length === 0" class="dictionary-state">{{ $t('socialnetwork.vocab.dictionary.empty') }}</div>
<table v-else class="dictionary-table">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.search.motherTongue') }}</th>
<th>{{ learningColumnLabel }}</th>
<th v-if="!isCourseMode">{{ $t('socialnetwork.vocab.chapters') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in results" :key="row.id != null ? `id-${row.id}` : `r-${idx}-${row.learning}-${row.reference}`">
<td>{{ row.reference }}</td>
<td>{{ row.learning }}</td>
<td v-if="!isCourseMode">{{ row.chapterTitle }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabDictionaryView',
data() {
return {
metaLoading: false,
listLoading: false,
contextTitle: '',
languageName: '',
q: '',
results: [],
error: '',
debounceId: null,
};
},
computed: {
isCourseMode() {
return Boolean(this.$route.params.courseId);
},
pageHeading() {
if (!this.contextTitle) return '';
if (this.isCourseMode) {
return this.$t('socialnetwork.vocab.dictionary.courseTitle', { name: this.contextTitle });
}
return this.$t('socialnetwork.vocab.dictionary.languageTitle', { name: this.contextTitle });
},
learningColumnLabel() {
if (this.isCourseMode) {
return this.$t('socialnetwork.vocab.dictionary.courseLearningColumn');
}
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
},
},
watch: {
'$route.fullPath'() {
this.bootstrap();
},
},
mounted() {
this.bootstrap();
},
beforeUnmount() {
if (this.debounceId) {
clearTimeout(this.debounceId);
}
},
methods: {
async bootstrap() {
this.q = '';
this.results = [];
this.error = '';
await this.loadContext();
if (!this.contextTitle) {
return;
}
await this.loadList();
},
scheduleLoad() {
if (this.debounceId) {
clearTimeout(this.debounceId);
}
this.debounceId = setTimeout(() => {
this.debounceId = null;
this.loadList();
}, 320);
},
async loadContext() {
this.metaLoading = true;
this.contextTitle = '';
this.languageName = '';
try {
if (this.isCourseMode) {
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}`);
this.contextTitle = data?.title || '';
} else {
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
this.contextTitle = data?.name || '';
this.languageName = data?.name || '';
}
} catch (e) {
console.error('Vocab dictionary context load failed:', e);
this.contextTitle = '';
} finally {
this.metaLoading = false;
}
},
async loadList() {
if (this.metaLoading) {
return;
}
const hasScope = this.isCourseMode
? Boolean(this.$route.params.courseId)
: Boolean(this.$route.params.languageId);
if (!hasScope) {
return;
}
this.listLoading = true;
this.error = '';
try {
const params = { q: this.q.trim() };
if (this.isCourseMode) {
const { data } = await apiClient.get(`/api/vocab/courses/${this.$route.params.courseId}/dictionary`, { params });
this.results = data?.results || [];
} else {
const { data } = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}/dictionary`, { params });
this.results = data?.results || [];
}
} catch (e) {
console.error('Vocab dictionary list failed:', e);
this.results = [];
this.error = this.$t('socialnetwork.vocab.dictionary.loadError');
} finally {
this.listLoading = false;
}
},
},
};
</script>
<style scoped>
.vocab-dictionary-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.dictionary-hero {
padding: 24px 26px;
margin-bottom: 16px;
}
.dictionary-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.dictionary-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.dictionary-toolbar {
padding: 16px 20px;
margin-bottom: 12px;
}
.dictionary-search {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 32rem;
}
.dictionary-search__label {
font-weight: 600;
font-size: 0.9rem;
}
.dictionary-search input {
width: 100%;
}
.dictionary-body {
padding: 16px 20px;
}
.dictionary-state {
color: var(--color-text-secondary);
}
.dictionary-state--error {
color: var(--color-danger, #b42318);
}
.dictionary-table {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
.dictionary-table th,
.dictionary-table td {
text-align: left;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
}
.dictionary-table th {
font-weight: 700;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
</style>

View File

@@ -24,6 +24,7 @@
<div class="row row--actions">
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
<button type="button" @click="goDictionary">{{ $t('socialnetwork.vocab.dictionary.open') }}</button>
</div>
<div class="row">
@@ -85,6 +86,9 @@ export default {
languageName: this.language?.name || '',
});
},
goDictionary() {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/dictionary`);
},
openChapter(chapterId) {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
},