Add search functionality for vocabulary in VocabController and VocabService

- Implemented a new searchVocabs method in VocabService to allow users to search for vocabulary based on learning and mother tongue terms.
- Updated VocabController to include the searchVocabs method wrapped with user authentication.
- Added a new route in vocabRouter for searching vocabulary by language ID.
- Enhanced VocabChapterView and VocabLanguageView components to include a button for opening the search dialog.
- Added translations for search-related terms in both German and English locales, improving user accessibility.
This commit is contained in:
Torsten Schulz (local)
2026-01-05 16:53:38 +01:00
parent dab3391aa2
commit f5e3a9a4a2
8 changed files with 266 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ class VocabController {
this.listChapters = this._wrapWithUser((userId, req) => this.service.listChapters(userId, req.params.languageId));
this.createChapter = this._wrapWithUser((userId, req) => this.service.createChapter(userId, req.params.languageId, req.body), { successStatus: 201 });
this.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
this.searchVocabs = this._wrapWithUser((userId, req) => this.service.searchVocabs(userId, req.params.languageId, req.query));
this.getChapter = this._wrapWithUser((userId, req) => this.service.getChapter(userId, req.params.chapterId));
this.listChapterVocabs = this._wrapWithUser((userId, req) => this.service.listChapterVocabs(userId, req.params.chapterId));

View File

@@ -16,6 +16,7 @@ router.get('/languages/:languageId', vocabController.getLanguage);
router.get('/languages/:languageId/chapters', vocabController.listChapters);
router.post('/languages/:languageId/chapters', vocabController.createChapter);
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
router.get('/languages/:languageId/search', vocabController.searchVocabs);
router.get('/chapters/:chapterId', vocabController.getChapter);
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);

View File

@@ -408,6 +408,52 @@ export default class VocabService {
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
}
async searchVocabs(hashedUserId, languageId, { learning = '', motherTongue = '' } = {}) {
const user = await this._getUserByHashedId(hashedUserId);
const access = await this._getLanguageAccess(user.id, languageId);
const learningTerm = typeof learning === 'string' ? learning.trim() : '';
const motherTerm = typeof motherTongue === 'string' ? motherTongue.trim() : '';
if (!learningTerm && !motherTerm) {
const err = new Error('Missing search term');
err.status = 400;
throw err;
}
const learningLike = learningTerm ? `%${learningTerm}%` : null;
const motherLike = motherTerm ? `%${motherTerm}%` : null;
const rows = await sequelize.query(
`
SELECT
cl.id,
c.id AS "chapterId",
c.title AS "chapterTitle",
l1.text AS "learning",
l2.text AS "motherTongue"
FROM community.vocab_chapter_lexeme cl
JOIN community.vocab_chapter c ON c.id = cl.chapter_id
JOIN community.vocab_lexeme l1 ON l1.id = cl.learning_lexeme_id
JOIN community.vocab_lexeme l2 ON l2.id = cl.reference_lexeme_id
WHERE c.language_id = :languageId
AND (:learningLike IS NULL OR l1.text ILIKE :learningLike)
AND (:motherLike IS NULL OR l2.text ILIKE :motherLike)
ORDER BY l2.text ASC, l1.text ASC, c.title ASC
LIMIT 200
`,
{
replacements: {
languageId: access.id,
learningLike,
motherLike,
},
type: sequelize.QueryTypes.SELECT,
}
);
return { languageId: access.id, results: rows };
}
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
const user = await this._getUserByHashedId(hashedUserId);
const ch = await this._getChapterAccess(user.id, chapterId);

View File

@@ -0,0 +1,170 @@
<template>
<DialogWidget
ref="dialog"
:title="$t('socialnetwork.vocab.search.title')"
:show-close="true"
:buttons="buttons"
:modal="true"
:isTitleTranslated="false"
width="60em"
height="34em"
name="VocabSearchDialog"
display="flex"
@close="close"
>
<div class="layout">
<div class="top">
<div class="row">
<label class="field">
{{ $t('socialnetwork.vocab.search.motherTongue') }}
<input v-model="motherTongue" type="text" @keydown.enter.prevent="runSearch" />
</label>
<label class="field">
{{ learningLabel }}
<input v-model="learning" type="text" @keydown.enter.prevent="runSearch" />
</label>
<button class="btn" :disabled="loading || (!motherTongue.trim() && !learning.trim())" @click="runSearch">
{{ loading ? $t('general.loading') : $t('socialnetwork.vocab.search.search') }}
</button>
</div>
</div>
<div class="body">
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="results.length === 0">
{{ $t('socialnetwork.vocab.search.noResults') }}
</div>
<table v-else class="tbl">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.search.motherTongue') }}</th>
<th>{{ learningLabel }}</th>
<th>{{ $t('socialnetwork.vocab.search.lesson') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in results" :key="r.id">
<td>{{ r.motherTongue }}</td>
<td>{{ r.learning }}</td>
<td>{{ r.chapterTitle }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'VocabSearchDialog',
components: { DialogWidget },
data() {
return {
languageId: null,
languageName: '',
motherTongue: '',
learning: '',
loading: false,
results: [],
error: '',
};
},
computed: {
buttons() {
return [{ text: this.$t('message.close'), action: this.close }];
},
learningLabel() {
return this.languageName || this.$t('socialnetwork.vocab.search.learningLanguage');
},
},
methods: {
open({ languageId, languageName = '' } = {}) {
this.languageId = languageId;
this.languageName = languageName || '';
this.motherTongue = '';
this.learning = '';
this.results = [];
this.error = '';
this.loading = false;
this.$refs.dialog.open();
this.$nextTick(() => {});
},
close() {
this.$refs.dialog.close();
},
async runSearch() {
if (!this.languageId) return;
const mt = this.motherTongue.trim();
const l = this.learning.trim();
if (!mt && !l) return;
this.loading = true;
this.error = '';
try {
const res = await apiClient.get(`/api/vocab/languages/${this.languageId}/search`, {
params: {
motherTongue: mt || undefined,
learning: l || undefined,
},
});
this.results = res.data?.results || [];
} catch (e) {
console.error('Search failed:', e);
this.results = [];
this.error = this.$t('socialnetwork.vocab.search.error');
} finally {
this.loading = false;
}
},
},
};
</script>
<style scoped>
.layout {
display: flex;
flex-direction: column;
gap: 10px;
height: 100%;
}
.top .row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.field input {
padding: 6px;
}
.btn {
padding: 8px 12px;
}
.body {
flex: 1;
overflow: auto;
}
.tbl {
width: 100%;
border-collapse: collapse;
}
.tbl th,
.tbl td {
border: 1px solid #ccc;
padding: 6px;
}
.error {
color: #b00020;
margin-bottom: 8px;
}
</style>

View File

@@ -308,6 +308,16 @@
"stats": "Statistik",
"success": "Erfolg",
"fail": "Misserfolg"
},
"search": {
"open": "Suche",
"title": "Vokabeln suchen",
"motherTongue": "Muttersprache",
"learningLanguage": "Lernsprache",
"lesson": "Lektion",
"search": "Suchen",
"noResults": "Keine Treffer.",
"error": "Suche fehlgeschlagen."
}
}
}

View File

@@ -308,6 +308,16 @@
"stats": "Stats",
"success": "Success",
"fail": "Fail"
},
"search": {
"open": "Search",
"title": "Search vocabulary",
"motherTongue": "Mother tongue",
"learningLanguage": "Learning language",
"lesson": "Lesson",
"search": "Search",
"noResults": "No results.",
"error": "Search failed."
}
}
}

View File

@@ -9,6 +9,7 @@
<div class="row">
<button @click="back">{{ $t('general.back') }}</button>
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div>
<div class="row" v-if="chapter.isOwner">
@@ -50,21 +51,24 @@
</div>
<VocabPracticeDialog ref="practiceDialog" />
<VocabSearchDialog ref="searchDialog" />
</template>
<script>
import apiClient from '@/utils/axios.js';
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
import VocabSearchDialog from '@/dialogues/socialnetwork/VocabSearchDialog.vue';
export default {
name: 'VocabChapterView',
components: { VocabPracticeDialog },
components: { VocabPracticeDialog, VocabSearchDialog },
data() {
return {
loading: false,
saving: false,
practiceOpen: false,
chapter: null,
languageName: '',
vocabs: [],
learning: '',
reference: '',
@@ -89,12 +93,24 @@ export default {
},
});
},
openSearch() {
this.$refs.searchDialog?.open?.({
languageId: this.$route.params.languageId,
languageName: this.languageName || '',
});
},
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 || [];
try {
const langRes = await apiClient.get(`/api/vocab/languages/${this.$route.params.languageId}`);
this.languageName = langRes.data?.name || '';
} catch (_) {
this.languageName = '';
}
} catch (e) {
console.error('Load chapter vocabs failed:', e);
this.chapter = null;

View File

@@ -17,6 +17,7 @@
<div class="row">
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div>
<hr />
@@ -48,13 +49,17 @@
</div>
</div>
</div>
<VocabSearchDialog ref="searchDialog" />
</template>
<script>
import apiClient from '@/utils/axios.js';
import VocabSearchDialog from '@/dialogues/socialnetwork/VocabSearchDialog.vue';
export default {
name: 'VocabLanguageView',
components: { VocabSearchDialog },
data() {
return {
loading: false,
@@ -69,6 +74,12 @@ export default {
goSubscribe() {
this.$router.push('/socialnetwork/vocab/subscribe');
},
openSearch() {
this.$refs.searchDialog?.open?.({
languageId: this.$route.params.languageId,
languageName: this.language?.name || '',
});
},
openChapter(chapterId) {
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
},