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:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
170
frontend/src/dialogues/socialnetwork/VocabSearchDialog.vue
Normal file
170
frontend/src/dialogues/socialnetwork/VocabSearchDialog.vue
Normal 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>
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user