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:
@@ -18,6 +18,7 @@ import taxiRouter from './routers/taxiRouter.js';
|
||||
import taxiMapRouter from './routers/taxiMapRouter.js';
|
||||
import taxiHighscoreRouter from './routers/taxiHighscoreRouter.js';
|
||||
import termineRouter from './routers/termineRouter.js';
|
||||
import vocabRouter from './routers/vocabRouter.js';
|
||||
import cors from 'cors';
|
||||
import './jobs/sessionCleanup.js';
|
||||
|
||||
@@ -69,6 +70,7 @@ app.use('/api/taxi/highscores', taxiHighscoreRouter);
|
||||
app.use('/images', express.static(path.join(__dirname, '../frontend/public/images')));
|
||||
app.use('/api/contact', contactRouter);
|
||||
app.use('/api/socialnetwork', socialnetworkRouter);
|
||||
app.use('/api/vocab', vocabRouter);
|
||||
app.use('/api/forum', forumRouter);
|
||||
app.use('/api/falukant', falukantRouter);
|
||||
app.use('/api/friendships', friendshipRouter);
|
||||
|
||||
@@ -4,6 +4,7 @@ import UserRight from '../models/community/user_right.js';
|
||||
import UserRightType from '../models/type/user_right.js';
|
||||
import UserParamType from '../models/type/user_param.js';
|
||||
import FalukantUser from '../models/falukant/data/user.js';
|
||||
import VocabService from '../services/vocabService.js';
|
||||
|
||||
const menuStructure = {
|
||||
home: {
|
||||
@@ -49,6 +50,11 @@ const menuStructure = {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/gallery"
|
||||
},
|
||||
vocabtrainer: {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/vocab",
|
||||
children: {}
|
||||
},
|
||||
blockedUsers: {
|
||||
visible: ["all"],
|
||||
path: "/socialnetwork/blocked"
|
||||
@@ -296,6 +302,7 @@ const menuStructure = {
|
||||
class NavigationController {
|
||||
constructor() {
|
||||
this.menu = this.menu.bind(this);
|
||||
this.vocabService = new VocabService();
|
||||
}
|
||||
|
||||
calculateAge(birthDate) {
|
||||
@@ -365,6 +372,24 @@ class NavigationController {
|
||||
const age = this.calculateAge(birthDate);
|
||||
const rights = userRights.map(ur => ur.rightType?.title).filter(Boolean);
|
||||
const filteredMenu = await this.filterMenu(menuStructure, rights, age, user.id);
|
||||
|
||||
// Dynamisches Submenü: Treffpunkt → Vokabeltrainer → (Neue Sprache + abonnierte/angelegte)
|
||||
// Wichtig: "Neue Sprache" soll IMMER sichtbar sein – auch wenn die DB-Abfrage (noch) fehlschlägt.
|
||||
if (filteredMenu?.socialnetwork?.children?.vocabtrainer) {
|
||||
const children = {
|
||||
newLanguage: { path: '/socialnetwork/vocab/new' },
|
||||
};
|
||||
try {
|
||||
const langs = await this.vocabService.listLanguagesForMenu(user.id);
|
||||
for (const l of langs) {
|
||||
children[`lang_${l.id}`] = { path: `/socialnetwork/vocab/${l.id}`, label: l.name };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[menu] Konnte Vokabeltrainer-Sprachen nicht laden:', e?.message || e);
|
||||
}
|
||||
filteredMenu.socialnetwork.children.vocabtrainer.children = children;
|
||||
}
|
||||
|
||||
res.status(200).json(filteredMenu);
|
||||
} catch (error) {
|
||||
console.error('Error fetching menu:', error);
|
||||
|
||||
45
backend/controllers/vocabController.js
Normal file
45
backend/controllers/vocabController.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import VocabService from '../services/vocabService.js';
|
||||
|
||||
function extractHashedUserId(req) {
|
||||
return req.headers?.userid;
|
||||
}
|
||||
|
||||
class VocabController {
|
||||
constructor() {
|
||||
this.service = new VocabService();
|
||||
|
||||
this.listLanguages = this._wrapWithUser((userId) => this.service.listLanguages(userId));
|
||||
this.createLanguage = this._wrapWithUser((userId, req) => this.service.createLanguage(userId, req.body), { successStatus: 201 });
|
||||
this.subscribe = this._wrapWithUser((userId, req) => this.service.subscribeByShareCode(userId, req.body), { successStatus: 201 });
|
||||
this.getLanguage = this._wrapWithUser((userId, req) => this.service.getLanguage(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.listLanguageVocabs = this._wrapWithUser((userId, req) => this.service.listLanguageVocabs(userId, req.params.languageId));
|
||||
|
||||
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.addVocabToChapter = this._wrapWithUser((userId, req) => this.service.addVocabToChapter(userId, req.params.chapterId, req.body), { successStatus: 201 });
|
||||
}
|
||||
|
||||
_wrapWithUser(fn, { successStatus = 200 } = {}) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
const hashedUserId = extractHashedUserId(req);
|
||||
if (!hashedUserId) {
|
||||
return res.status(400).json({ error: 'Missing user identifier' });
|
||||
}
|
||||
const result = await fn(hashedUserId, req, res);
|
||||
res.status(successStatus).json(result);
|
||||
} catch (error) {
|
||||
console.error('Controller error:', error);
|
||||
const status = error.status && typeof error.status === 'number' ? error.status : 500;
|
||||
res.status(status).json({ error: error.message || 'Internal error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default VocabController;
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable */
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
// Sprache / Set, das geteilt werden kann
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_language (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
share_code TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_language_owner_fk
|
||||
FOREIGN KEY (owner_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
|
||||
);
|
||||
`);
|
||||
|
||||
// Abos (Freunde)
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
language_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_language_subscription_user_fk
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_language_subscription_language_fk
|
||||
FOREIGN KEY (language_id)
|
||||
REFERENCES community.vocab_language(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
|
||||
ON community.vocab_language(owner_user_id);
|
||||
`);
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
|
||||
ON community.vocab_language_subscription(user_id);
|
||||
`);
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
|
||||
ON community.vocab_language_subscription(language_id);
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language_subscription;`);
|
||||
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_language;`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/* eslint-disable */
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
// Kapitel innerhalb einer Sprache
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_chapter_language_fk
|
||||
FOREIGN KEY (language_id)
|
||||
REFERENCES community.vocab_language(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chapter_creator_fk
|
||||
FOREIGN KEY (created_by_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
|
||||
ON community.vocab_chapter(language_id);
|
||||
`);
|
||||
|
||||
// Lexeme/Wörter (wir deduplizieren pro Sprache über normalized)
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language_id INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
normalized TEXT NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_lexeme_language_fk
|
||||
FOREIGN KEY (language_id)
|
||||
REFERENCES community.vocab_language(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_lexeme_creator_fk
|
||||
FOREIGN KEY (created_by_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
|
||||
ON community.vocab_lexeme(language_id);
|
||||
`);
|
||||
|
||||
// n:m Zuordnung pro Kapitel: Lernwort ↔ Referenzwort (Mehrdeutigkeiten möglich)
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
|
||||
id SERIAL PRIMARY KEY,
|
||||
chapter_id INTEGER NOT NULL,
|
||||
learning_lexeme_id INTEGER NOT NULL,
|
||||
reference_lexeme_id INTEGER NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_chlex_chapter_fk
|
||||
FOREIGN KEY (chapter_id)
|
||||
REFERENCES community.vocab_chapter(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_learning_fk
|
||||
FOREIGN KEY (learning_lexeme_id)
|
||||
REFERENCES community.vocab_lexeme(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_reference_fk
|
||||
FOREIGN KEY (reference_lexeme_id)
|
||||
REFERENCES community.vocab_lexeme(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_creator_fk
|
||||
FOREIGN KEY (created_by_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
|
||||
);
|
||||
`);
|
||||
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
|
||||
ON community.vocab_chapter_lexeme(chapter_id);
|
||||
`);
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
|
||||
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
||||
`);
|
||||
await queryInterface.sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
||||
ON community.vocab_chapter_lexeme(reference_lexeme_id);
|
||||
`);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter_lexeme;`);
|
||||
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_lexeme;`);
|
||||
await queryInterface.sequelize.query(`DROP TABLE IF EXISTS community.vocab_chapter;`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
26
backend/routers/vocabRouter.js
Normal file
26
backend/routers/vocabRouter.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import VocabController from '../controllers/vocabController.js';
|
||||
|
||||
const router = express.Router();
|
||||
const vocabController = new VocabController();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/languages', vocabController.listLanguages);
|
||||
router.post('/languages', vocabController.createLanguage);
|
||||
router.post('/subscribe', vocabController.subscribe);
|
||||
router.get('/languages/:languageId', vocabController.getLanguage);
|
||||
|
||||
// Kapitel
|
||||
router.get('/languages/:languageId/chapters', vocabController.listChapters);
|
||||
router.post('/languages/:languageId/chapters', vocabController.createChapter);
|
||||
router.get('/languages/:languageId/vocabs', vocabController.listLanguageVocabs);
|
||||
|
||||
router.get('/chapters/:chapterId', vocabController.getChapter);
|
||||
router.get('/chapters/:chapterId/vocabs', vocabController.listChapterVocabs);
|
||||
router.post('/chapters/:chapterId/vocabs', vocabController.addVocabToChapter);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
485
backend/services/vocabService.js
Normal file
485
backend/services/vocabService.js
Normal file
@@ -0,0 +1,485 @@
|
||||
import crypto from 'crypto';
|
||||
import User from '../models/community/user.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import { notifyUser } from '../utils/socket.js';
|
||||
|
||||
export default class VocabService {
|
||||
async _getUserByHashedId(hashedUserId) {
|
||||
const user = await User.findOne({ where: { hashedId: hashedUserId } });
|
||||
if (!user) {
|
||||
const err = new Error('User not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
_normalizeLexeme(text) {
|
||||
return String(text || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
async _getLanguageAccess(userId, languageId) {
|
||||
const id = Number.parseInt(languageId, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
const err = new Error('Invalid language id');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [row] = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
l.id,
|
||||
(l.owner_user_id = :userId) AS "isOwner"
|
||||
FROM community.vocab_language l
|
||||
WHERE l.id = :languageId
|
||||
AND (
|
||||
l.owner_user_id = :userId
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM community.vocab_language_subscription s
|
||||
WHERE s.user_id = :userId AND s.language_id = l.id
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
`,
|
||||
{
|
||||
replacements: { userId, languageId: id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
const err = new Error('Language not found or no access');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async _getChapterAccess(userId, chapterId) {
|
||||
const id = Number.parseInt(chapterId, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
const err = new Error('Invalid chapter id');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [row] = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
c.id,
|
||||
c.language_id AS "languageId",
|
||||
c.title,
|
||||
(l.owner_user_id = :userId) AS "isOwner"
|
||||
FROM community.vocab_chapter c
|
||||
JOIN community.vocab_language l ON l.id = c.language_id
|
||||
WHERE c.id = :chapterId
|
||||
AND (
|
||||
l.owner_user_id = :userId
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM community.vocab_language_subscription s
|
||||
WHERE s.user_id = :userId AND s.language_id = l.id
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
`,
|
||||
{
|
||||
replacements: { userId, chapterId: id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
const err = new Error('Chapter not found or no access');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async listLanguages(hashedUserId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
l.id,
|
||||
l.name,
|
||||
l.share_code AS "shareCode",
|
||||
TRUE AS "isOwner"
|
||||
FROM community.vocab_language l
|
||||
WHERE l.owner_user_id = :userId
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
l.id,
|
||||
l.name,
|
||||
NULL::text AS "shareCode",
|
||||
FALSE AS "isOwner"
|
||||
FROM community.vocab_language_subscription s
|
||||
JOIN community.vocab_language l ON l.id = s.language_id
|
||||
WHERE s.user_id = :userId
|
||||
|
||||
ORDER BY name ASC
|
||||
`,
|
||||
{
|
||||
replacements: { userId: user.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
return { languages: rows };
|
||||
}
|
||||
|
||||
async listLanguagesForMenu(userId) {
|
||||
// userId ist die numerische community.user.id
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT l.id, l.name
|
||||
FROM community.vocab_language l
|
||||
WHERE l.owner_user_id = :userId
|
||||
UNION
|
||||
SELECT l.id, l.name
|
||||
FROM community.vocab_language_subscription s
|
||||
JOIN community.vocab_language l ON l.id = s.language_id
|
||||
WHERE s.user_id = :userId
|
||||
ORDER BY name ASC
|
||||
`,
|
||||
{
|
||||
replacements: { userId },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async createLanguage(hashedUserId, { name }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const cleanName = typeof name === 'string' ? name.trim() : '';
|
||||
if (!cleanName || cleanName.length < 2 || cleanName.length > 60) {
|
||||
const err = new Error('Invalid language name');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 16 hex chars => ausreichend kurz, gut teilbar
|
||||
const shareCode = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
const [created] = await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.vocab_language (owner_user_id, name, share_code)
|
||||
VALUES (:ownerUserId, :name, :shareCode)
|
||||
RETURNING id, name, share_code AS "shareCode"
|
||||
`,
|
||||
{
|
||||
replacements: { ownerUserId: user.id, name: cleanName, shareCode },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
// Menü dynamisch nachladen (bei allen offenen Tabs/Clients)
|
||||
try {
|
||||
notifyUser(user.hashedId, 'reloadmenu', {});
|
||||
} catch (_) {}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async subscribeByShareCode(hashedUserId, { shareCode }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const code = typeof shareCode === 'string' ? shareCode.trim() : '';
|
||||
if (!code || code.length < 6 || code.length > 128) {
|
||||
const err = new Error('Invalid share code');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [lang] = await sequelize.query(
|
||||
`
|
||||
SELECT id, owner_user_id AS "ownerUserId", name
|
||||
FROM community.vocab_language
|
||||
WHERE share_code = :shareCode
|
||||
LIMIT 1
|
||||
`,
|
||||
{
|
||||
replacements: { shareCode: code },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
if (!lang) {
|
||||
const err = new Error('Language not found');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Owner braucht kein Abo
|
||||
if (lang.ownerUserId === user.id) {
|
||||
return { subscribed: false, message: 'Already owner', languageId: lang.id };
|
||||
}
|
||||
|
||||
await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.vocab_language_subscription (user_id, language_id)
|
||||
VALUES (:userId, :languageId)
|
||||
ON CONFLICT (user_id, language_id) DO NOTHING
|
||||
`,
|
||||
{
|
||||
replacements: { userId: user.id, languageId: lang.id },
|
||||
type: sequelize.QueryTypes.INSERT,
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
notifyUser(user.hashedId, 'reloadmenu', {});
|
||||
} catch (_) {}
|
||||
|
||||
return { subscribed: true, languageId: lang.id, name: lang.name };
|
||||
}
|
||||
|
||||
async getLanguage(hashedUserId, languageId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const id = Number.parseInt(languageId, 10);
|
||||
if (!Number.isFinite(id)) {
|
||||
const err = new Error('Invalid language id');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [row] = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
l.id,
|
||||
l.name,
|
||||
CASE WHEN l.owner_user_id = :userId THEN l.share_code ELSE NULL END AS "shareCode",
|
||||
(l.owner_user_id = :userId) AS "isOwner"
|
||||
FROM community.vocab_language l
|
||||
WHERE l.id = :languageId
|
||||
AND (
|
||||
l.owner_user_id = :userId
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM community.vocab_language_subscription s
|
||||
WHERE s.user_id = :userId AND s.language_id = l.id
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
`,
|
||||
{
|
||||
replacements: { userId: user.id, languageId: id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
const err = new Error('Language not found or no access');
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async listChapters(hashedUserId, languageId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const access = await this._getLanguageAccess(user.id, languageId);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
c.id,
|
||||
c.title,
|
||||
c.created_at AS "createdAt",
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM community.vocab_chapter_lexeme cl
|
||||
WHERE cl.chapter_id = c.id
|
||||
)::int AS "vocabCount"
|
||||
FROM community.vocab_chapter c
|
||||
WHERE c.language_id = :languageId
|
||||
ORDER BY c.title ASC
|
||||
`,
|
||||
{
|
||||
replacements: { languageId: access.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
return { chapters: rows, isOwner: access.isOwner };
|
||||
}
|
||||
|
||||
async createChapter(hashedUserId, languageId, { title }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const access = await this._getLanguageAccess(user.id, languageId);
|
||||
if (!access.isOwner) {
|
||||
const err = new Error('Only owner can create chapters');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const cleanTitle = typeof title === 'string' ? title.trim() : '';
|
||||
if (!cleanTitle || cleanTitle.length < 2 || cleanTitle.length > 80) {
|
||||
const err = new Error('Invalid chapter title');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [created] = await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.vocab_chapter (language_id, title, created_by_user_id)
|
||||
VALUES (:languageId, :title, :userId)
|
||||
RETURNING id, title, created_at AS "createdAt"
|
||||
`,
|
||||
{
|
||||
replacements: { languageId: access.id, title: cleanTitle, userId: user.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async getChapter(hashedUserId, chapterId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||
return { id: ch.id, languageId: ch.languageId, title: ch.title, isOwner: ch.isOwner };
|
||||
}
|
||||
|
||||
async listChapterVocabs(hashedUserId, chapterId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
cl.id,
|
||||
l1.text AS "learning",
|
||||
l2.text AS "reference",
|
||||
cl.created_at AS "createdAt"
|
||||
FROM community.vocab_chapter_lexeme cl
|
||||
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 cl.chapter_id = :chapterId
|
||||
ORDER BY l1.text ASC, l2.text ASC
|
||||
`,
|
||||
{
|
||||
replacements: { chapterId: ch.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
return { chapter: { id: ch.id, title: ch.title, languageId: ch.languageId, isOwner: ch.isOwner }, vocabs: rows };
|
||||
}
|
||||
|
||||
async listLanguageVocabs(hashedUserId, languageId) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const access = await this._getLanguageAccess(user.id, languageId);
|
||||
|
||||
const rows = await sequelize.query(
|
||||
`
|
||||
SELECT
|
||||
cl.id,
|
||||
c.id AS "chapterId",
|
||||
c.title AS "chapterTitle",
|
||||
l1.text AS "learning",
|
||||
l2.text AS "reference",
|
||||
cl.created_at AS "createdAt"
|
||||
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
|
||||
ORDER BY c.title ASC, l1.text ASC, l2.text ASC
|
||||
`,
|
||||
{
|
||||
replacements: { languageId: access.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
}
|
||||
);
|
||||
|
||||
return { languageId: access.id, isOwner: access.isOwner, vocabs: rows };
|
||||
}
|
||||
|
||||
async addVocabToChapter(hashedUserId, chapterId, { learning, reference }) {
|
||||
const user = await this._getUserByHashedId(hashedUserId);
|
||||
const ch = await this._getChapterAccess(user.id, chapterId);
|
||||
if (!ch.isOwner) {
|
||||
const err = new Error('Only owner can add vocab');
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const learningText = typeof learning === 'string' ? learning.trim() : '';
|
||||
const referenceText = typeof reference === 'string' ? reference.trim() : '';
|
||||
if (!learningText || !referenceText) {
|
||||
const err = new Error('Invalid vocab');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const learningNorm = this._normalizeLexeme(learningText);
|
||||
const referenceNorm = this._normalizeLexeme(referenceText);
|
||||
|
||||
// Transaktion: Lexeme upserten + Zuordnung setzen
|
||||
return await sequelize.transaction(async (t) => {
|
||||
const [learningLex] = await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
|
||||
VALUES (:languageId, :text, :normalized, :userId)
|
||||
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
|
||||
RETURNING id
|
||||
`,
|
||||
{
|
||||
replacements: { languageId: ch.languageId, text: learningText, normalized: learningNorm, userId: user.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
transaction: t,
|
||||
}
|
||||
);
|
||||
|
||||
const [referenceLex] = await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.vocab_lexeme (language_id, text, normalized, created_by_user_id)
|
||||
VALUES (:languageId, :text, :normalized, :userId)
|
||||
ON CONFLICT (language_id, normalized) DO UPDATE SET text = EXCLUDED.text
|
||||
RETURNING id
|
||||
`,
|
||||
{
|
||||
replacements: { languageId: ch.languageId, text: referenceText, normalized: referenceNorm, userId: user.id },
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
transaction: t,
|
||||
}
|
||||
);
|
||||
|
||||
const [mapping] = await sequelize.query(
|
||||
`
|
||||
INSERT INTO community.vocab_chapter_lexeme (chapter_id, learning_lexeme_id, reference_lexeme_id, created_by_user_id)
|
||||
VALUES (:chapterId, :learningId, :referenceId, :userId)
|
||||
ON CONFLICT (chapter_id, learning_lexeme_id, reference_lexeme_id) DO NOTHING
|
||||
RETURNING id
|
||||
`,
|
||||
{
|
||||
replacements: {
|
||||
chapterId: ch.id,
|
||||
learningId: learningLex.id,
|
||||
referenceId: referenceLex.id,
|
||||
userId: user.id,
|
||||
},
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
transaction: t,
|
||||
}
|
||||
);
|
||||
|
||||
return { created: Boolean(mapping?.id) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,123 @@ const syncDatabase = async () => {
|
||||
console.log("Initializing database schemas...");
|
||||
await initializeDatabase();
|
||||
|
||||
// Vokabeltrainer: Tabellen sicherstellen (auch ohne manuell ausgeführte Migrations)
|
||||
// Hintergrund: In Produktion sind Schema-Updates deaktiviert, und Migrations werden nicht automatisch ausgeführt.
|
||||
// Damit API/Menu nicht mit "relation does not exist" (42P01) scheitert, legen wir die Tabellen idempotent an.
|
||||
console.log("Ensuring Vocab-Trainer tables exist...");
|
||||
try {
|
||||
await sequelize.query(`
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_language (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
share_code TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_language_owner_fk
|
||||
FOREIGN KEY (owner_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_language_share_code_uniq UNIQUE (share_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_language_subscription (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
language_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_language_subscription_user_fk
|
||||
FOREIGN KEY (user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_language_subscription_language_fk
|
||||
FOREIGN KEY (language_id)
|
||||
REFERENCES community.vocab_language(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_language_subscription_uniq UNIQUE (user_id, language_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS vocab_language_owner_idx
|
||||
ON community.vocab_language(owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS vocab_language_subscription_user_idx
|
||||
ON community.vocab_language_subscription(user_id);
|
||||
CREATE INDEX IF NOT EXISTS vocab_language_subscription_language_idx
|
||||
ON community.vocab_language_subscription(language_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_chapter (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_chapter_language_fk
|
||||
FOREIGN KEY (language_id)
|
||||
REFERENCES community.vocab_language(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chapter_creator_fk
|
||||
FOREIGN KEY (created_by_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS vocab_chapter_language_idx
|
||||
ON community.vocab_chapter(language_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_lexeme (
|
||||
id SERIAL PRIMARY KEY,
|
||||
language_id INTEGER NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
normalized TEXT NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_lexeme_language_fk
|
||||
FOREIGN KEY (language_id)
|
||||
REFERENCES community.vocab_language(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_lexeme_creator_fk
|
||||
FOREIGN KEY (created_by_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_lexeme_unique_per_language UNIQUE (language_id, normalized)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS vocab_lexeme_language_idx
|
||||
ON community.vocab_lexeme(language_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS community.vocab_chapter_lexeme (
|
||||
id SERIAL PRIMARY KEY,
|
||||
chapter_id INTEGER NOT NULL,
|
||||
learning_lexeme_id INTEGER NOT NULL,
|
||||
reference_lexeme_id INTEGER NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT vocab_chlex_chapter_fk
|
||||
FOREIGN KEY (chapter_id)
|
||||
REFERENCES community.vocab_chapter(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_learning_fk
|
||||
FOREIGN KEY (learning_lexeme_id)
|
||||
REFERENCES community.vocab_lexeme(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_reference_fk
|
||||
FOREIGN KEY (reference_lexeme_id)
|
||||
REFERENCES community.vocab_lexeme(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_creator_fk
|
||||
FOREIGN KEY (created_by_user_id)
|
||||
REFERENCES community."user"(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT vocab_chlex_unique UNIQUE (chapter_id, learning_lexeme_id, reference_lexeme_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS vocab_chlex_chapter_idx
|
||||
ON community.vocab_chapter_lexeme(chapter_id);
|
||||
CREATE INDEX IF NOT EXISTS vocab_chlex_learning_idx
|
||||
ON community.vocab_chapter_lexeme(learning_lexeme_id);
|
||||
CREATE INDEX IF NOT EXISTS vocab_chlex_reference_idx
|
||||
ON community.vocab_chapter_lexeme(reference_lexeme_id);
|
||||
`);
|
||||
console.log("✅ Vocab-Trainer Tabellen sind vorhanden.");
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Konnte Vocab-Trainer Tabellen nicht sicherstellen:', e?.message || e);
|
||||
}
|
||||
|
||||
// Vorab: Stelle kritische Spalten sicher, damit Index-Erstellung nicht fehlschlägt
|
||||
console.log("Pre-ensure Taxi columns (traffic_light) ...");
|
||||
try {
|
||||
|
||||
BIN
frontend/public/sounds/fail.mp3
Normal file
BIN
frontend/public/sounds/fail.mp3
Normal file
Binary file not shown.
BIN
frontend/public/sounds/success.mp3
Normal file
BIN
frontend/public/sounds/success.mp3
Normal file
Binary file not shown.
@@ -27,7 +27,7 @@
|
||||
:style="`background-image:url('/images/icons/${subitem.icon}')`"
|
||||
class="submenu-icon"
|
||||
> </span>
|
||||
<span>{{ $t(`navigation.m-${key}.${subkey}`) }}</span>
|
||||
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
|
||||
<span
|
||||
v-if="subkey === 'forum' || subitem.children"
|
||||
class="subsubmenu"
|
||||
@@ -62,7 +62,7 @@
|
||||
:style="`background-image:url('/images/icons/${subsubitem.icon}')`"
|
||||
class="submenu-icon"
|
||||
> </span>
|
||||
<span>{{ $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
|
||||
<span>{{ subsubitem?.label || $t(`navigation.m-${key}.m-${subkey}.${subsubkey}`) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
504
frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue
Normal file
504
frontend/src/dialogues/socialnetwork/VocabPracticeDialog.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<DialogWidget
|
||||
ref="dialog"
|
||||
:title="$t('socialnetwork.vocab.practice.title')"
|
||||
:show-close="true"
|
||||
:buttons="buttons"
|
||||
:modal="true"
|
||||
:isTitleTranslated="false"
|
||||
width="55em"
|
||||
height="32em"
|
||||
name="VocabPracticeDialog"
|
||||
display="flex"
|
||||
@close="close"
|
||||
>
|
||||
<div class="layout">
|
||||
<div class="left">
|
||||
<div class="opts">
|
||||
<label class="chk">
|
||||
<input type="checkbox" v-model="allVocabs" @change="reloadPool" />
|
||||
{{ $t('socialnetwork.vocab.practice.allVocabs') }}
|
||||
</label>
|
||||
<label class="chk">
|
||||
<input type="checkbox" v-model="simpleMode" @change="onSimpleModeChanged" />
|
||||
{{ $t('socialnetwork.vocab.practice.simple') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="pool.length === 0">
|
||||
{{ $t('socialnetwork.vocab.practice.noPool') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="prompt">
|
||||
<div class="dir">{{ directionLabel }}</div>
|
||||
<div class="word">{{ currentPrompt }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="answered" class="feedback" :class="{ ok: lastCorrect, bad: !lastCorrect }">
|
||||
<div v-if="lastCorrect">{{ $t('socialnetwork.vocab.practice.correct') }}</div>
|
||||
<div v-else>
|
||||
{{ $t('socialnetwork.vocab.practice.wrong') }}
|
||||
<div class="answers">
|
||||
<div class="answersTitle">{{ $t('socialnetwork.vocab.practice.acceptable') }}</div>
|
||||
<ul>
|
||||
<li v-for="a in acceptableAnswers" :key="a">{{ a }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!answered" class="answerArea">
|
||||
<div v-if="simpleMode" class="choices">
|
||||
<button
|
||||
v-for="opt in choiceOptions"
|
||||
:key="opt"
|
||||
class="choiceBtn"
|
||||
:disabled="locked"
|
||||
@click="submitChoice(opt)"
|
||||
>
|
||||
{{ opt }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="typing">
|
||||
<input
|
||||
ref="answerInput"
|
||||
v-model="typedAnswer"
|
||||
type="text"
|
||||
:disabled="locked"
|
||||
@keydown.enter.prevent="submitTyped"
|
||||
/>
|
||||
<button :disabled="locked || typedAnswer.trim().length === 0" @click="submitTyped">
|
||||
{{ $t('socialnetwork.vocab.practice.check') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button v-if="showNextButton" @click="next">
|
||||
{{ $t('socialnetwork.vocab.practice.next') }}
|
||||
</button>
|
||||
<button v-else-if="showSkipButton" @click="skip">
|
||||
{{ $t('socialnetwork.vocab.practice.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="stat">
|
||||
<div class="statTitle">{{ $t('socialnetwork.vocab.practice.stats') }}</div>
|
||||
<div class="statRow">
|
||||
<span class="k">{{ $t('socialnetwork.vocab.practice.success') }}</span>
|
||||
<span class="v">{{ correctCount }} ({{ successPercent }}%)</span>
|
||||
</div>
|
||||
<div class="statRow">
|
||||
<span class="k">{{ $t('socialnetwork.vocab.practice.fail') }}</span>
|
||||
<span class="v">{{ wrongCount }} ({{ failPercent }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
|
||||
export default {
|
||||
name: 'VocabPracticeDialog',
|
||||
components: { DialogWidget },
|
||||
data() {
|
||||
return {
|
||||
openParams: null, // { languageId, chapterId }
|
||||
loading: false,
|
||||
allVocabs: false,
|
||||
simpleMode: false,
|
||||
pool: [],
|
||||
|
||||
// session stats
|
||||
correctCount: 0,
|
||||
wrongCount: 0,
|
||||
perId: {}, // { [id]: { c, w, streak, lastAsked } }
|
||||
lastIds: [],
|
||||
|
||||
// current question
|
||||
current: null, // { id, learning, reference }
|
||||
direction: 'L2R', // L2R: learning->reference, R2L: reference->learning
|
||||
acceptableAnswers: [],
|
||||
choiceOptions: [],
|
||||
typedAnswer: '',
|
||||
answered: false,
|
||||
lastCorrect: false,
|
||||
locked: false,
|
||||
autoAdvanceTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
buttons() {
|
||||
return [{ text: this.$t('message.close'), action: this.close }];
|
||||
},
|
||||
totalCount() {
|
||||
return this.correctCount + this.wrongCount;
|
||||
},
|
||||
successPercent() {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return Math.round((this.correctCount / this.totalCount) * 100);
|
||||
},
|
||||
failPercent() {
|
||||
if (this.totalCount === 0) return 0;
|
||||
return Math.round((this.wrongCount / this.totalCount) * 100);
|
||||
},
|
||||
currentPrompt() {
|
||||
if (!this.current) return '';
|
||||
return this.direction === 'L2R' ? this.current.learning : this.current.reference;
|
||||
},
|
||||
directionLabel() {
|
||||
return this.direction === 'L2R'
|
||||
? this.$t('socialnetwork.vocab.practice.dirLearningToRef')
|
||||
: this.$t('socialnetwork.vocab.practice.dirRefToLearning');
|
||||
},
|
||||
showNextButton() {
|
||||
// Nur bei falscher Antwort auf "Weiter" warten
|
||||
return this.answered && !this.lastCorrect;
|
||||
},
|
||||
showSkipButton() {
|
||||
return !this.answered;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
open({ languageId, chapterId }) {
|
||||
if (this.autoAdvanceTimer) {
|
||||
clearTimeout(this.autoAdvanceTimer);
|
||||
this.autoAdvanceTimer = null;
|
||||
}
|
||||
this.openParams = { languageId, chapterId };
|
||||
this.allVocabs = false;
|
||||
this.simpleMode = false;
|
||||
this.correctCount = 0;
|
||||
this.wrongCount = 0;
|
||||
this.perId = {};
|
||||
this.lastIds = [];
|
||||
this.pool = [];
|
||||
this.locked = false;
|
||||
this.resetQuestion();
|
||||
this.$refs.dialog.open();
|
||||
this.reloadPool();
|
||||
},
|
||||
close() {
|
||||
if (this.autoAdvanceTimer) {
|
||||
clearTimeout(this.autoAdvanceTimer);
|
||||
this.autoAdvanceTimer = null;
|
||||
}
|
||||
this.$refs.dialog.close();
|
||||
},
|
||||
normalize(s) {
|
||||
return String(s || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ');
|
||||
},
|
||||
resetQuestion() {
|
||||
this.current = null;
|
||||
this.direction = Math.random() < 0.5 ? 'L2R' : 'R2L';
|
||||
this.acceptableAnswers = [];
|
||||
this.choiceOptions = [];
|
||||
this.typedAnswer = '';
|
||||
this.answered = false;
|
||||
this.lastCorrect = false;
|
||||
this.locked = false;
|
||||
},
|
||||
onSimpleModeChanged() {
|
||||
if (this.autoAdvanceTimer) {
|
||||
clearTimeout(this.autoAdvanceTimer);
|
||||
this.autoAdvanceTimer = null;
|
||||
}
|
||||
this.locked = false;
|
||||
this.answered = false;
|
||||
this.lastCorrect = false;
|
||||
this.typedAnswer = '';
|
||||
|
||||
if (!this.pool || this.pool.length === 0) return;
|
||||
|
||||
// Wenn wir aktuell keine Frage haben, sofort eine neue ziehen.
|
||||
if (!this.current) {
|
||||
this.next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Aktuelle Frage behalten, nur UI/Antwortmodus neu aufbauen
|
||||
const prompt = this.currentPrompt;
|
||||
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
|
||||
if (this.simpleMode) {
|
||||
this.buildChoices();
|
||||
} else {
|
||||
this.choiceOptions = [];
|
||||
this.$nextTick(() => this.$refs.answerInput?.focus?.());
|
||||
}
|
||||
},
|
||||
async reloadPool() {
|
||||
if (!this.openParams) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
let res;
|
||||
if (this.allVocabs) {
|
||||
res = await apiClient.get(`/api/vocab/languages/${this.openParams.languageId}/vocabs`);
|
||||
this.pool = res.data?.vocabs || [];
|
||||
} else {
|
||||
res = await apiClient.get(`/api/vocab/chapters/${this.openParams.chapterId}/vocabs`);
|
||||
this.pool = res.data?.vocabs || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Reload pool failed:', e);
|
||||
this.pool = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.next();
|
||||
}
|
||||
},
|
||||
getAnswersForPrompt(prompt, direction) {
|
||||
const p = this.normalize(prompt);
|
||||
const answers = new Set();
|
||||
for (const item of this.pool) {
|
||||
const itemPrompt = direction === 'L2R' ? item.learning : item.reference;
|
||||
if (this.normalize(itemPrompt) === p) {
|
||||
const a = direction === 'L2R' ? item.reference : item.learning;
|
||||
answers.add(a);
|
||||
}
|
||||
}
|
||||
return Array.from(answers);
|
||||
},
|
||||
computeWeight(item) {
|
||||
const st = this.perId[item.id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
|
||||
let w = 1;
|
||||
w += st.w * 2.5;
|
||||
w *= Math.pow(0.7, st.c);
|
||||
if (st.streak > 0) {
|
||||
w *= Math.pow(0.8, st.streak);
|
||||
} else if (st.streak < 0) {
|
||||
w *= 1 + Math.min(5, Math.abs(st.streak));
|
||||
}
|
||||
if (this.lastIds.includes(item.id)) w *= 0.1;
|
||||
return Math.max(0.05, Math.min(50, w));
|
||||
},
|
||||
pickNextItem() {
|
||||
const items = this.pool;
|
||||
if (!items || items.length === 0) return null;
|
||||
const weights = items.map((it) => this.computeWeight(it));
|
||||
const sum = weights.reduce((a, b) => a + b, 0);
|
||||
let r = Math.random() * sum;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
r -= weights[i];
|
||||
if (r <= 0) return items[i];
|
||||
}
|
||||
return items[items.length - 1];
|
||||
},
|
||||
buildChoices() {
|
||||
const prompt = this.currentPrompt;
|
||||
const acceptable = this.getAnswersForPrompt(prompt, this.direction);
|
||||
this.acceptableAnswers = acceptable;
|
||||
|
||||
const options = new Set();
|
||||
// 1) mindestens eine richtige Übersetzung
|
||||
options.add(acceptable[0] || (this.direction === 'L2R' ? this.current.reference : this.current.learning));
|
||||
// 2) weitere Übersetzungen (Mehrdeutigkeiten) fürs gleiche Wort
|
||||
for (const a of acceptable) {
|
||||
if (options.size >= 3) break;
|
||||
options.add(a);
|
||||
}
|
||||
// 3) Distraktoren aus anderen Wörtern
|
||||
const allAnswers = this.pool.map((it) => (this.direction === 'L2R' ? it.reference : it.learning));
|
||||
for (let i = 0; i < 50 && options.size < 4; i++) {
|
||||
const cand = allAnswers[Math.floor(Math.random() * allAnswers.length)];
|
||||
if (!acceptable.map(this.normalize).includes(this.normalize(cand))) {
|
||||
options.add(cand);
|
||||
}
|
||||
}
|
||||
|
||||
const arr = Array.from(options);
|
||||
// shuffle
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
this.choiceOptions = arr;
|
||||
},
|
||||
async playSound(ok) {
|
||||
try {
|
||||
const audio = new Audio(ok ? '/sounds/success.mp3' : '/sounds/fail.mp3');
|
||||
await audio.play();
|
||||
} catch (_) {
|
||||
// ignore autoplay issues
|
||||
}
|
||||
},
|
||||
markResult(isCorrect) {
|
||||
this.answered = true;
|
||||
this.lastCorrect = isCorrect;
|
||||
if (isCorrect) this.correctCount += 1;
|
||||
else this.wrongCount += 1;
|
||||
|
||||
const id = this.current?.id;
|
||||
if (!id) return;
|
||||
const st = this.perId[id] || { c: 0, w: 0, streak: 0, lastAsked: 0 };
|
||||
if (isCorrect) {
|
||||
st.c += 1;
|
||||
st.streak = st.streak >= 0 ? st.streak + 1 : 1;
|
||||
} else {
|
||||
st.w += 1;
|
||||
st.streak = st.streak <= 0 ? st.streak - 1 : -1;
|
||||
}
|
||||
st.lastAsked = Date.now();
|
||||
this.perId[id] = st;
|
||||
|
||||
this.lastIds.unshift(id);
|
||||
this.lastIds = this.lastIds.slice(0, 3);
|
||||
},
|
||||
submitChoice(opt) {
|
||||
if (this.locked) return;
|
||||
const ok = this.acceptableAnswers.map(this.normalize).includes(this.normalize(opt));
|
||||
this.markResult(ok);
|
||||
this.playSound(ok);
|
||||
if (ok) {
|
||||
// Direkt weiter zur nächsten Frage (kein Klick nötig)
|
||||
this.locked = true;
|
||||
this.autoAdvanceTimer = setTimeout(() => {
|
||||
this.autoAdvanceTimer = null;
|
||||
this.next();
|
||||
}, 350);
|
||||
}
|
||||
},
|
||||
submitTyped() {
|
||||
if (this.locked) return;
|
||||
const ans = this.normalize(this.typedAnswer);
|
||||
const ok = this.acceptableAnswers.map(this.normalize).includes(ans);
|
||||
this.markResult(ok);
|
||||
this.playSound(ok);
|
||||
if (ok) {
|
||||
this.locked = true;
|
||||
this.autoAdvanceTimer = setTimeout(() => {
|
||||
this.autoAdvanceTimer = null;
|
||||
this.next();
|
||||
}, 350);
|
||||
}
|
||||
},
|
||||
skip() {
|
||||
this.next();
|
||||
},
|
||||
next() {
|
||||
if (this.autoAdvanceTimer) {
|
||||
clearTimeout(this.autoAdvanceTimer);
|
||||
this.autoAdvanceTimer = null;
|
||||
}
|
||||
if (!this.pool || this.pool.length === 0) {
|
||||
this.resetQuestion();
|
||||
return;
|
||||
}
|
||||
this.resetQuestion();
|
||||
this.current = this.pickNextItem();
|
||||
if (!this.current) return;
|
||||
const prompt = this.currentPrompt;
|
||||
this.acceptableAnswers = this.getAnswersForPrompt(prompt, this.direction);
|
||||
if (this.simpleMode) this.buildChoices();
|
||||
this.$nextTick(() => {
|
||||
if (!this.simpleMode) this.$refs.answerInput?.focus?.();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
.left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.right {
|
||||
width: 16em;
|
||||
border-left: 1px solid #ddd;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.opts {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.chk {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.prompt {
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dir {
|
||||
color: #555;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.word {
|
||||
font-size: 1.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.typing input {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
}
|
||||
.choices {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.choiceBtn {
|
||||
padding: 8px;
|
||||
}
|
||||
.controls {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.feedback {
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback.ok {
|
||||
background: #e8ffe8;
|
||||
border-color: #7bbe55;
|
||||
}
|
||||
.feedback.bad {
|
||||
background: #ffecec;
|
||||
border-color: #d33;
|
||||
}
|
||||
.answersTitle {
|
||||
margin-top: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.statTitle {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.statRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.k {
|
||||
color: #333;
|
||||
}
|
||||
.v {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"datetimelong": "dd.MM.yyyy HH:mm:ss"
|
||||
"datetimelong": "dd.MM.yyyy HH:mm:ss",
|
||||
"loading": "Lädt...",
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"OK": "Ok",
|
||||
"Cancel": "Abbrechen",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"usersearch": "Benutzersuche",
|
||||
"forum": "Forum",
|
||||
"gallery": "Galerie",
|
||||
"vocabtrainer": "Vokabeltrainer",
|
||||
"blockedUsers": "Blockierte Benutzer",
|
||||
"oneTimeInvitation": "Einmal-Einladungen",
|
||||
"diary": "Tagebuch",
|
||||
@@ -27,6 +28,9 @@
|
||||
"m-erotic": {
|
||||
"pictures": "Bilder",
|
||||
"videos": "Videos"
|
||||
},
|
||||
"m-vocabtrainer": {
|
||||
"newLanguage": "Neue Sprache"
|
||||
}
|
||||
},
|
||||
"m-minigames": {
|
||||
|
||||
@@ -249,5 +249,66 @@
|
||||
"denied": "Du hast die Freundschaftsanfrage abgelehnt.",
|
||||
"accepted": "Die Freundschaft wurde geschlossen."
|
||||
}
|
||||
,
|
||||
"vocab": {
|
||||
"title": "Vokabeltrainer",
|
||||
"description": "Lege Sprachen an (oder abonniere sie) und teile sie mit Freunden.",
|
||||
"newLanguage": "Neue Sprache",
|
||||
"newLanguageTitle": "Neue Sprache anlegen",
|
||||
"languageName": "Name der Sprache",
|
||||
"create": "Anlegen",
|
||||
"saving": "Speichere...",
|
||||
"created": "Sprache wurde angelegt.",
|
||||
"createdTitle": "Vokabeltrainer",
|
||||
"createdMessage": "Sprache wurde angelegt. Das Menü wird aktualisiert.",
|
||||
"createError": "Konnte die Sprache nicht anlegen.",
|
||||
"openLanguage": "Öffnen",
|
||||
"none": "Du hast noch keine Sprachen angelegt oder abonniert.",
|
||||
"owner": "Eigen",
|
||||
"subscribed": "Abonniert",
|
||||
"languageTitle": "Vokabeltrainer: {name}",
|
||||
"notFound": "Sprache nicht gefunden oder kein Zugriff.",
|
||||
"shareCode": "Teilen-Code",
|
||||
"shareHint": "Diesen Code kannst du an Freunde weitergeben, damit sie die Sprache abonnieren können.",
|
||||
"subscribeByCode": "Per Code abonnieren",
|
||||
"subscribeTitle": "Sprache abonnieren",
|
||||
"subscribeHint": "Gib den Teilen-Code ein, den du von einem Freund bekommen hast.",
|
||||
"subscribe": "Abonnieren",
|
||||
"subscribeSuccess": "Abo erfolgreich. Menü wird aktualisiert.",
|
||||
"subscribeError": "Abo fehlgeschlagen. Code ungültig oder kein Zugriff.",
|
||||
"trainerPlaceholder": "Trainer-Funktionen (Vokabeln/Abfragen) kommen als nächster Schritt."
|
||||
,
|
||||
"chapters": "Kapitel",
|
||||
"newChapter": "Neues Kapitel",
|
||||
"createChapter": "Kapitel anlegen",
|
||||
"createChapterError": "Konnte Kapitel nicht anlegen.",
|
||||
"noChapters": "Noch keine Kapitel vorhanden.",
|
||||
"chapterTitle": "Kapitel: {title}",
|
||||
"addVocab": "Vokabel hinzufügen",
|
||||
"learningWord": "Lernsprache",
|
||||
"referenceWord": "Referenz",
|
||||
"add": "Hinzufügen",
|
||||
"addVocabError": "Konnte Vokabel nicht hinzufügen.",
|
||||
"noVocabs": "In diesem Kapitel sind noch keine Vokabeln."
|
||||
,
|
||||
"practice": {
|
||||
"open": "Üben",
|
||||
"title": "Vokabeln üben",
|
||||
"allVocabs": "Alle Vokabeln",
|
||||
"simple": "Einfaches Üben",
|
||||
"noPool": "Keine Vokabeln zum Üben vorhanden.",
|
||||
"dirLearningToRef": "Lernsprache → Referenz",
|
||||
"dirRefToLearning": "Referenz → Lernsprache",
|
||||
"check": "Prüfen",
|
||||
"next": "Weiter",
|
||||
"skip": "Überspringen",
|
||||
"correct": "Richtig!",
|
||||
"wrong": "Falsch.",
|
||||
"acceptable": "Mögliche richtige Übersetzungen:",
|
||||
"stats": "Statistik",
|
||||
"success": "Erfolg",
|
||||
"fail": "Misserfolg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
"dataPrivacy": {
|
||||
"title": "Data Privacy Policy"
|
||||
},
|
||||
"general": {
|
||||
"loading": "Loading...",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"datetimelong": "dd.MM.yyyy HH:mm:ss"
|
||||
},
|
||||
"message": {
|
||||
"close": "Close"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"usersearch": "User search",
|
||||
"forum": "Forum",
|
||||
"gallery": "Gallery",
|
||||
"vocabtrainer": "Vocabulary trainer",
|
||||
"blockedUsers": "Blocked users",
|
||||
"oneTimeInvitation": "One-time invitations",
|
||||
"diary": "Diary",
|
||||
@@ -27,6 +28,9 @@
|
||||
"m-erotic": {
|
||||
"pictures": "Pictures",
|
||||
"videos": "Videos"
|
||||
},
|
||||
"m-vocabtrainer": {
|
||||
"newLanguage": "New language"
|
||||
}
|
||||
},
|
||||
"m-minigames": {
|
||||
|
||||
@@ -249,5 +249,66 @@
|
||||
"denied": "You have denied the friendship request.",
|
||||
"accepted": "The friendship has been established."
|
||||
}
|
||||
,
|
||||
"vocab": {
|
||||
"title": "Vocabulary trainer",
|
||||
"description": "Create languages (or subscribe to them) and share them with friends.",
|
||||
"newLanguage": "New language",
|
||||
"newLanguageTitle": "Create new language",
|
||||
"languageName": "Language name",
|
||||
"create": "Create",
|
||||
"saving": "Saving...",
|
||||
"created": "Language created.",
|
||||
"createdTitle": "Vocabulary trainer",
|
||||
"createdMessage": "Language created. The menu will refresh.",
|
||||
"createError": "Could not create language.",
|
||||
"openLanguage": "Open",
|
||||
"none": "You have no languages yet (created or subscribed).",
|
||||
"owner": "Owned",
|
||||
"subscribed": "Subscribed",
|
||||
"languageTitle": "Vocabulary trainer: {name}",
|
||||
"notFound": "Language not found or no access.",
|
||||
"shareCode": "Share code",
|
||||
"shareHint": "Send this code to friends so they can subscribe to this language.",
|
||||
"subscribeByCode": "Subscribe by code",
|
||||
"subscribeTitle": "Subscribe to language",
|
||||
"subscribeHint": "Enter a share code you received from a friend.",
|
||||
"subscribe": "Subscribe",
|
||||
"subscribeSuccess": "Subscribed. The menu will refresh.",
|
||||
"subscribeError": "Subscribe failed. Invalid code or no access.",
|
||||
"trainerPlaceholder": "Trainer features (words/quizzes) will be the next step."
|
||||
,
|
||||
"chapters": "Chapters",
|
||||
"newChapter": "New chapter",
|
||||
"createChapter": "Create chapter",
|
||||
"createChapterError": "Could not create chapter.",
|
||||
"noChapters": "No chapters yet.",
|
||||
"chapterTitle": "Chapter: {title}",
|
||||
"addVocab": "Add vocabulary",
|
||||
"learningWord": "To learn",
|
||||
"referenceWord": "Reference",
|
||||
"add": "Add",
|
||||
"addVocabError": "Could not add vocabulary.",
|
||||
"noVocabs": "No vocabulary in this chapter yet."
|
||||
,
|
||||
"practice": {
|
||||
"open": "Practice",
|
||||
"title": "Practice vocabulary",
|
||||
"allVocabs": "All vocabulary",
|
||||
"simple": "Simple practice",
|
||||
"noPool": "No vocabulary to practice.",
|
||||
"dirLearningToRef": "To learn → Reference",
|
||||
"dirRefToLearning": "Reference → To learn",
|
||||
"check": "Check",
|
||||
"next": "Next",
|
||||
"skip": "Skip",
|
||||
"correct": "Correct!",
|
||||
"wrong": "Wrong.",
|
||||
"acceptable": "Acceptable answers:",
|
||||
"stats": "Stats",
|
||||
"success": "Success",
|
||||
"fail": "Fail"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import GuestbookView from '../views/social/GuestbookView.vue';
|
||||
import DiaryView from '../views/social/DiaryView.vue';
|
||||
import ForumView from '../views/social/ForumView.vue';
|
||||
import ForumTopicView from '../views/social/ForumTopicView.vue';
|
||||
import VocabTrainerView from '../views/social/VocabTrainerView.vue';
|
||||
import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue';
|
||||
import VocabLanguageView from '../views/social/VocabLanguageView.vue';
|
||||
import VocabSubscribeView from '../views/social/VocabSubscribeView.vue';
|
||||
import VocabChapterView from '../views/social/VocabChapterView.vue';
|
||||
|
||||
const socialRoutes = [
|
||||
{
|
||||
@@ -49,6 +54,36 @@ const socialRoutes = [
|
||||
component: DiaryView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab',
|
||||
name: 'VocabTrainer',
|
||||
component: VocabTrainerView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/new',
|
||||
name: 'VocabNewLanguage',
|
||||
component: VocabNewLanguageView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/subscribe',
|
||||
name: 'VocabSubscribe',
|
||||
component: VocabSubscribeView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/:languageId',
|
||||
name: 'VocabLanguage',
|
||||
component: VocabLanguageView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/socialnetwork/vocab/:languageId/chapters/:chapterId',
|
||||
name: 'VocabChapter',
|
||||
component: VocabChapterView,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
];
|
||||
|
||||
export default socialRoutes;
|
||||
|
||||
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