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.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.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.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.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));
|
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.get('/languages/:languageId/chapters', vocabController.listChapters);
|
||||||
router.post('/languages/:languageId/chapters', vocabController.createChapter);
|
router.post('/languages/:languageId/chapters', vocabController.createChapter);
|
||||||
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
|
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
|
||||||
|
router.get('/languages/:languageId/search', vocabController.searchVocabs);
|
||||||
|
|
||||||
router.get('/chapters/:chapterId', vocabController.getChapter);
|
router.get('/chapters/:chapterId', vocabController.getChapter);
|
||||||
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
||||||
|
|||||||
@@ -408,6 +408,52 @@ export default class VocabService {
|
|||||||
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
|
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 }) {
|
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
||||||
const user = await this._getUserByHashedId(hashedUserId);
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
const ch = await this._getChapterAccess(user.id, chapterId);
|
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",
|
"stats": "Statistik",
|
||||||
"success": "Erfolg",
|
"success": "Erfolg",
|
||||||
"fail": "Misserfolg"
|
"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",
|
"stats": "Stats",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"fail": "Fail"
|
"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">
|
<div class="row">
|
||||||
<button @click="back">{{ $t('general.back') }}</button>
|
<button @click="back">{{ $t('general.back') }}</button>
|
||||||
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</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>
|
||||||
|
|
||||||
<div class="row" v-if="chapter.isOwner">
|
<div class="row" v-if="chapter.isOwner">
|
||||||
@@ -50,21 +51,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VocabPracticeDialog ref="practiceDialog" />
|
<VocabPracticeDialog ref="practiceDialog" />
|
||||||
|
<VocabSearchDialog ref="searchDialog" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
|
import VocabPracticeDialog from '@/dialogues/socialnetwork/VocabPracticeDialog.vue';
|
||||||
|
import VocabSearchDialog from '@/dialogues/socialnetwork/VocabSearchDialog.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'VocabChapterView',
|
name: 'VocabChapterView',
|
||||||
components: { VocabPracticeDialog },
|
components: { VocabPracticeDialog, VocabSearchDialog },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
practiceOpen: false,
|
practiceOpen: false,
|
||||||
chapter: null,
|
chapter: null,
|
||||||
|
languageName: '',
|
||||||
vocabs: [],
|
vocabs: [],
|
||||||
learning: '',
|
learning: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
@@ -89,12 +93,24 @@ export default {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
openSearch() {
|
||||||
|
this.$refs.searchDialog?.open?.({
|
||||||
|
languageId: this.$route.params.languageId,
|
||||||
|
languageName: this.languageName || '',
|
||||||
|
});
|
||||||
|
},
|
||||||
async load() {
|
async load() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get(`/api/vocab/chapters/${this.$route.params.chapterId}/vocabs`);
|
const res = await apiClient.get(`/api/vocab/chapters/${this.$route.params.chapterId}/vocabs`);
|
||||||
this.chapter = res.data?.chapter || null;
|
this.chapter = res.data?.chapter || null;
|
||||||
this.vocabs = res.data?.vocabs || [];
|
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) {
|
} catch (e) {
|
||||||
console.error('Load chapter vocabs failed:', e);
|
console.error('Load chapter vocabs failed:', e);
|
||||||
this.chapter = null;
|
this.chapter = null;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
|
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
|
||||||
|
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
@@ -48,13 +49,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VocabSearchDialog ref="searchDialog" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
|
import VocabSearchDialog from '@/dialogues/socialnetwork/VocabSearchDialog.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'VocabLanguageView',
|
name: 'VocabLanguageView',
|
||||||
|
components: { VocabSearchDialog },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -69,6 +74,12 @@ export default {
|
|||||||
goSubscribe() {
|
goSubscribe() {
|
||||||
this.$router.push('/socialnetwork/vocab/subscribe');
|
this.$router.push('/socialnetwork/vocab/subscribe');
|
||||||
},
|
},
|
||||||
|
openSearch() {
|
||||||
|
this.$refs.searchDialog?.open?.({
|
||||||
|
languageId: this.$route.params.languageId,
|
||||||
|
languageName: this.language?.name || '',
|
||||||
|
});
|
||||||
|
},
|
||||||
openChapter(chapterId) {
|
openChapter(chapterId) {
|
||||||
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
|
this.$router.push(`/socialnetwork/vocab/${this.$route.params.languageId}/chapters/${chapterId}`);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user