Compare commits
33 Commits
a7688e4ed5
...
Redesign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d44a265ca | ||
|
|
4442937ebd | ||
|
|
59869e077e | ||
|
|
971e09a72a | ||
|
|
bf2b490731 | ||
|
|
fd41a53404 | ||
|
|
a48e907e50 | ||
|
|
a117bad342 | ||
|
|
190cf626f9 | ||
|
|
2bc34acacf | ||
|
|
5f4acbea51 | ||
|
|
6d4ada7b31 | ||
|
|
1bccee3429 | ||
|
|
947d3d0694 | ||
|
|
e76fdbe1ab | ||
|
|
db8be34607 | ||
|
|
407c3b359b | ||
|
|
a2652c983f | ||
|
|
42fe568e2b | ||
|
|
ea7f8d1acc | ||
|
|
af4e5de1ad | ||
|
|
cc80081280 | ||
|
|
444a1b9dcc | ||
|
|
91637ba7a3 | ||
|
|
be7db6ad96 | ||
|
|
a3b550859c | ||
|
|
c58f8c0bf8 | ||
|
|
73304e8af4 | ||
|
|
e21c61b5e3 | ||
|
|
78a44b5189 | ||
|
|
da1d912bdb | ||
|
|
c45a843611 | ||
|
|
b07099b57d |
@@ -36,6 +36,19 @@ const app = express();
|
||||
// - LOG_ALL_REQ=1: Logge alle Requests
|
||||
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
|
||||
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
|
||||
const defaultCorsOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://127.0.0.1:5173'
|
||||
];
|
||||
const corsOrigins = (process.env.CORS_ORIGINS || process.env.FRONTEND_URL || '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
const effectiveCorsOrigins = corsOrigins.length > 0 ? corsOrigins : defaultCorsOrigins;
|
||||
const corsAllowAll = process.env.CORS_ALLOW_ALL === '1';
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
|
||||
req.reqId = reqId;
|
||||
@@ -51,15 +64,26 @@ app.use((req, res, next) => {
|
||||
});
|
||||
|
||||
const corsOptions = {
|
||||
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
|
||||
origin(origin, callback) {
|
||||
if (!origin) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (corsAllowAll || effectiveCorsOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
return callback(null, false);
|
||||
},
|
||||
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'userId', 'authCode'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'userid', 'authcode', 'userId', 'authCode'],
|
||||
credentials: true,
|
||||
preflightContinue: false,
|
||||
optionsSuccessStatus: 204
|
||||
};
|
||||
|
||||
app.use(cors(corsOptions));
|
||||
app.options('*', cors(corsOptions));
|
||||
app.use(express.json()); // To handle JSON request bodies
|
||||
|
||||
app.use('/api/chat', chatRouter);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"host": "localhost",
|
||||
"port": 1235
|
||||
"port": 1236
|
||||
}
|
||||
|
||||
@@ -12,26 +12,52 @@ const productionEnvPath = '/opt/yourpart/backend/.env';
|
||||
const localEnvPath = path.resolve(__dirname, '../.env');
|
||||
|
||||
let envPath = localEnvPath; // Fallback
|
||||
let usingProduction = false;
|
||||
if (fs.existsSync(productionEnvPath)) {
|
||||
// Prüfe Lesbarkeit bevor wir versuchen, sie zu laden
|
||||
try {
|
||||
fs.accessSync(productionEnvPath, fs.constants.R_OK);
|
||||
envPath = productionEnvPath;
|
||||
console.log('[env] Lade Produktions-.env:', productionEnvPath);
|
||||
usingProduction = true;
|
||||
console.log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
|
||||
} catch (err) {
|
||||
console.warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
|
||||
console.warn('[env] Fehler:', err && err.message);
|
||||
envPath = localEnvPath;
|
||||
}
|
||||
} else {
|
||||
console.log('[env] Lade lokale .env:', localEnvPath);
|
||||
console.log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
|
||||
}
|
||||
|
||||
// Lade .env-Datei
|
||||
// Lade .env-Datei (robust gegen Fehler)
|
||||
console.log('[env] Versuche .env zu laden von:', envPath);
|
||||
console.log('[env] Datei existiert:', fs.existsSync(envPath));
|
||||
console.log('[env] Datei lesbar:', fs.accessSync ? (() => { try { fs.accessSync(envPath, fs.constants.R_OK); return true; } catch { return false; } })() : 'unbekannt');
|
||||
|
||||
const result = dotenv.config({ path: envPath });
|
||||
let result;
|
||||
try {
|
||||
result = dotenv.config({ path: envPath });
|
||||
if (result.error) {
|
||||
console.warn('[env] Konnte .env nicht laden:', result.error.message);
|
||||
console.warn('[env] Fehler-Details:', result.error);
|
||||
} else {
|
||||
console.log('[env] .env erfolgreich geladen von:', envPath);
|
||||
console.log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
|
||||
console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
|
||||
}
|
||||
} catch (err) {
|
||||
// Sollte nicht passieren, aber falls dotenv intern eine Exception wirft (z.B. EACCES), fange sie ab
|
||||
console.warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
|
||||
console.warn('[env] Stack:', err && err.stack);
|
||||
if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) {
|
||||
console.log('[env] Versuche stattdessen lokale .env:', localEnvPath);
|
||||
try {
|
||||
result = dotenv.config({ path: localEnvPath });
|
||||
if (!result.error) {
|
||||
console.log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
|
||||
}
|
||||
} catch (err2) {
|
||||
console.warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Zeige Redis-Konfiguration
|
||||
console.log('[env] Redis-Konfiguration:');
|
||||
|
||||
@@ -13,6 +13,9 @@ class ChatController {
|
||||
this.sendOneToOneMessage = this.sendOneToOneMessage.bind(this);
|
||||
this.getOneToOneMessageHistory = this.getOneToOneMessageHistory.bind(this);
|
||||
this.getRoomList = this.getRoomList.bind(this);
|
||||
this.getRoomCreateOptions = this.getRoomCreateOptions.bind(this);
|
||||
this.getOwnRooms = this.getOwnRooms.bind(this);
|
||||
this.deleteOwnRoom = this.deleteOwnRoom.bind(this);
|
||||
}
|
||||
|
||||
async getMessages(req, res) {
|
||||
@@ -175,6 +178,41 @@ class ChatController {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getRoomCreateOptions(req, res) {
|
||||
try {
|
||||
const options = await chatService.getRoomCreateOptions();
|
||||
res.status(200).json(options);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getOwnRooms(req, res) {
|
||||
try {
|
||||
const { userid: hashedUserId } = req.headers;
|
||||
const rooms = await chatService.getOwnRooms(hashedUserId);
|
||||
res.status(200).json(rooms);
|
||||
} catch (error) {
|
||||
const status = error.message === 'user_not_found' ? 404 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteOwnRoom(req, res) {
|
||||
try {
|
||||
const { userid: hashedUserId } = req.headers;
|
||||
const roomId = Number.parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(roomId) || roomId <= 0) {
|
||||
return res.status(400).json({ error: 'invalid_room_id' });
|
||||
}
|
||||
await chatService.deleteOwnRoom(hashedUserId, roomId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
const status = error.message === 'room_not_found_or_not_owner' || error.message === 'user_not_found' ? 404 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatController;
|
||||
|
||||
@@ -98,6 +98,8 @@ class FalukantController {
|
||||
if (!result) throw { status: 404, message: 'No family data found' };
|
||||
return result;
|
||||
});
|
||||
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
|
||||
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId));
|
||||
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
|
||||
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
|
||||
this.cancelWooing = this._wrapWithUser(async (userId) => {
|
||||
|
||||
19
backend/migrations/add_chat_room_dialog_fields.sql
Normal file
19
backend/migrations/add_chat_room_dialog_fields.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE chat.room
|
||||
ADD COLUMN IF NOT EXISTS gender_restriction_id INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS min_age INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS max_age INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS password VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS friends_of_owner_only BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS required_user_right_id INTEGER;
|
||||
|
||||
UPDATE chat.room
|
||||
SET friends_of_owner_only = FALSE
|
||||
WHERE friends_of_owner_only IS NULL;
|
||||
|
||||
ALTER TABLE chat.room
|
||||
ALTER COLUMN friends_of_owner_only SET DEFAULT FALSE,
|
||||
ALTER COLUMN friends_of_owner_only SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -15,5 +15,8 @@ router.post('/initOneToOne', authenticate, chatController.initOneToOne);
|
||||
router.post('/oneToOne/sendMessage', authenticate, chatController.sendOneToOneMessage); // Neue Route zum Senden einer Nachricht
|
||||
router.get('/oneToOne/messageHistory', authenticate, chatController.getOneToOneMessageHistory); // Neue Route zum Abrufen der Nachrichtengeschichte
|
||||
router.get('/rooms', chatController.getRoomList);
|
||||
router.get('/room-create-options', authenticate, chatController.getRoomCreateOptions);
|
||||
router.get('/my-rooms', authenticate, chatController.getOwnRooms);
|
||||
router.delete('/my-rooms/:id', authenticate, chatController.deleteOwnRoom);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -47,6 +47,8 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
|
||||
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
|
||||
router.post('/family/cancel-wooing', falukantController.cancelWooing);
|
||||
router.post('/family/set-heir', falukantController.setHeir);
|
||||
router.get('/heirs/potential', falukantController.getPotentialHeirs);
|
||||
router.post('/heirs/select', falukantController.selectHeir);
|
||||
router.get('/family/gifts', falukantController.getGifts);
|
||||
router.get('/family/children', falukantController.getChildren);
|
||||
router.post('/family/gift', falukantController.sendGift);
|
||||
|
||||
@@ -191,6 +191,189 @@ const BISAYA_EXERCISES = {
|
||||
}
|
||||
],
|
||||
|
||||
// Lektion: Haus & Familie (Balay, Kwarto, Kusina, Pamilya)
|
||||
'Haus & Familie': [
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Wie sagt man "Haus" auf Bisaya?',
|
||||
instruction: 'Wähle die richtige Übersetzung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Wie sagt man "Haus" auf Bisaya?',
|
||||
options: ['Balay', 'Kwarto', 'Kusina', 'Pamilya']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Balay" bedeutet "Haus" auf Bisaya.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Wie sagt man "Zimmer" auf Bisaya?',
|
||||
instruction: 'Wähle die richtige Übersetzung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Wie sagt man "Zimmer" auf Bisaya?',
|
||||
options: ['Kwarto', 'Balay', 'Kusina', 'Pamilya']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Kwarto" bedeutet "Zimmer" (Raum/Schlafzimmer).'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Wie sagt man "Küche" auf Bisaya?',
|
||||
instruction: 'Wähle die richtige Übersetzung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Wie sagt man "Küche" auf Bisaya?',
|
||||
options: ['Kusina', 'Balay', 'Kwarto', 'Pamilya']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Kusina" bedeutet "Küche".'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Wie sagt man "Familie" auf Bisaya?',
|
||||
instruction: 'Wähle die richtige Übersetzung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Wie sagt man "Familie" auf Bisaya?',
|
||||
options: ['Pamilya', 'Balay', 'Kwarto', 'Kusina']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Pamilya" bedeutet "Familie".'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 1, // gap_fill
|
||||
title: 'Haus & Räume vervollständigen',
|
||||
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: '{gap} (Haus) | {gap} (Zimmer) | {gap} (Küche) | {gap} (Familie)',
|
||||
gaps: 4
|
||||
},
|
||||
answerData: {
|
||||
type: 'gap_fill',
|
||||
answers: ['Balay', 'Kwarto', 'Kusina', 'Pamilya']
|
||||
},
|
||||
explanation: 'Balay = Haus, Kwarto = Zimmer, Kusina = Küche, Pamilya = Familie.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 4, // transformation
|
||||
title: 'Haus-Satz übersetzen',
|
||||
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||
questionData: {
|
||||
type: 'transformation',
|
||||
text: 'Unser Haus hat zwei Zimmer',
|
||||
sourceLanguage: 'Deutsch',
|
||||
targetLanguage: 'Bisaya'
|
||||
},
|
||||
answerData: {
|
||||
type: 'transformation',
|
||||
correct: 'Ang among balay kay naay duha ka kwarto',
|
||||
alternatives: ['Among balay naay duha ka kwarto', 'Ang among balay adunay duha ka kwarto']
|
||||
},
|
||||
explanation: '"Balay" = Haus, "kwarto" = Zimmer, "duha ka" = zwei (Stück).'
|
||||
}
|
||||
],
|
||||
|
||||
// Lektion 14: Ort & Richtung (Asa, dinhi, didto, padulong)
|
||||
'Ort & Richtung': [
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Was bedeutet \"Asa\"?',
|
||||
instruction: 'Wähle die richtige Bedeutung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet \"Asa\" auf Bisaya?',
|
||||
options: ['Wo / Wohin', 'Hier', 'Dort', 'Warum']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '\"Asa\" bedeutet \"Wo\" oder je nach Kontext \"Wohin\".'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Was bedeutet \"dinhi\"?',
|
||||
instruction: 'Wähle die richtige Bedeutung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet \"dinhi\" auf Bisaya?',
|
||||
options: ['Hier', 'Dort', 'Drinnen', 'Draußen']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '\"dinhi\" bedeutet \"hier\".'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Was bedeutet \"didto\"?',
|
||||
instruction: 'Wähle die richtige Bedeutung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet \"didto\" auf Bisaya?',
|
||||
options: ['Dort', 'Hier', 'Oben', 'Unten']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '\"didto\" bedeutet \"dort\" (an einem entfernten Ort).'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Was bedeutet \"padulong\"?',
|
||||
instruction: 'Wähle die richtige Bedeutung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet \"padulong\" auf Bisaya?',
|
||||
options: ['Unterwegs nach / auf dem Weg zu', 'Ankommen', 'Abfahren', 'Zurückkommen']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '\"padulong\" beschreibt eine Bewegung in Richtung eines Zieles.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 1, // gap_fill
|
||||
title: 'Ort-Wörter einsetzen',
|
||||
instruction: 'Fülle die Lücken mit den richtigen Ort-Wörtern.',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: '{gap} ka? (Wo bist du?) | Naa ko {gap}. (Ich bin hier.) | Adto ta {gap}. (Lass uns dorthin gehen.)',
|
||||
gaps: 3
|
||||
},
|
||||
answerData: {
|
||||
type: 'gap_fill',
|
||||
answers: ['Asa', 'dinhi', 'didto']
|
||||
},
|
||||
explanation: 'Asa = wo, dinhi = hier, didto = dort.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 1, // gap_fill
|
||||
title: 'Richtungen beschreiben',
|
||||
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: '{gap} ko sa merkado. (Ich gehe zum Markt.) | {gap} ta didto. (Lass uns dorthin gehen.)',
|
||||
gaps: 2
|
||||
},
|
||||
answerData: {
|
||||
type: 'gap_fill',
|
||||
answers: ['Padulong', 'Padulong']
|
||||
},
|
||||
explanation: '\"Padulong\" beschreibt, dass man unterwegs zu einem Ziel ist.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 4, // transformation
|
||||
title: 'Frage nach dem Ort übersetzen',
|
||||
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||
questionData: {
|
||||
type: 'transformation',
|
||||
text: 'Wo ist die Kirche?',
|
||||
sourceLanguage: 'Deutsch',
|
||||
targetLanguage: 'Bisaya'
|
||||
},
|
||||
answerData: {
|
||||
type: 'transformation',
|
||||
correct: 'Asa ang simbahan?',
|
||||
alternatives: ['Asa dapit ang simbahan?', 'Asa man ang simbahan?']
|
||||
},
|
||||
explanation: '\"simbahan\" = Kirche, \"Asa ang ...?\" = Wo ist ...?'
|
||||
}
|
||||
],
|
||||
|
||||
// Lektion 15: Zeitformen - Grundlagen
|
||||
'Zeitformen - Grundlagen': [
|
||||
{
|
||||
@@ -743,6 +926,122 @@ const BISAYA_EXERCISES = {
|
||||
}
|
||||
],
|
||||
|
||||
// Lektion 13: Alltagsgespräche - Teil 2
|
||||
'Alltagsgespräche - Teil 2': [
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Wie sagt man "Wohin gehst du?"?',
|
||||
instruction: 'Wähle die richtige Frage aus.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Wie sagt man "Wohin gehst du?" auf Bisaya?',
|
||||
options: ['Asa ka padulong?', 'Kumusta ka?', 'Unsa imong ginabuhat?', 'Tagpila ni?']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Asa ka padulong?" bedeutet "Wohin gehst du?" - "Asa" = wo/wohin, "padulong" = unterwegs nach.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Wie sagt man "Was ist dein Plan?"?',
|
||||
instruction: 'Wähle die richtige Frage aus.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Wie sagt man "Was ist dein Plan?" auf Bisaya?',
|
||||
options: ['Unsa imong plano?', 'Asa ka padulong?', 'Unsa imong ngalan?', 'Kinsa ka?']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Unsa imong plano?" bedeutet "Was ist dein Plan?" - "plano" = Plan.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Was bedeutet "Moadto ko sa balay"?',
|
||||
instruction: 'Wähle die richtige Übersetzung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet "Moadto ko sa balay"?',
|
||||
options: ['Ich gehe nach Hause', 'Ich gehe zur Arbeit', 'Ich gehe in die Schule', 'Ich gehe in die Kirche']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Moadto ko sa balay" bedeutet "Ich gehe nach Hause" - "balay" = Haus.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 2, // multiple_choice
|
||||
title: 'Was bedeutet "Moadto ko sa merkado"?',
|
||||
instruction: 'Wähle die richtige Übersetzung.',
|
||||
questionData: {
|
||||
type: 'multiple_choice',
|
||||
question: 'Was bedeutet "Moadto ko sa merkado"?',
|
||||
options: ['Ich gehe zum Markt', 'Ich gehe nach Hause', 'Ich gehe in die Kirche', 'Ich gehe ins Krankenhaus']
|
||||
},
|
||||
answerData: { type: 'multiple_choice', correctAnswer: 0 },
|
||||
explanation: '"Moadto ko sa merkado" bedeutet "Ich gehe zum Markt" - "merkado" = Markt.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 1, // gap_fill
|
||||
title: 'Fragen zum Weg vervollständigen',
|
||||
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: '{gap} ka padulong? (Wohin gehst du?) | {gap} imong plano? (Was ist dein Plan?)',
|
||||
gaps: 2
|
||||
},
|
||||
answerData: {
|
||||
type: 'gap_fill',
|
||||
answers: ['Asa', 'Unsa']
|
||||
},
|
||||
explanation: '"Asa" = wo/wohin, "Unsa" = was.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 1, // gap_fill
|
||||
title: 'Antworten auf Weg-Fragen',
|
||||
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
|
||||
questionData: {
|
||||
type: 'gap_fill',
|
||||
text: 'Moadto ko sa {gap}. (Ich gehe nach Hause.) | Moadto ko sa {gap}. (Ich gehe zum Markt.)',
|
||||
gaps: 2
|
||||
},
|
||||
answerData: {
|
||||
type: 'gap_fill',
|
||||
answers: ['balay', 'merkado']
|
||||
},
|
||||
explanation: '"balay" = Haus, "merkado" = Markt.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 4, // transformation
|
||||
title: 'Weg-Satz übersetzen 1',
|
||||
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||
questionData: {
|
||||
type: 'transformation',
|
||||
text: 'Ich gehe nach Hause',
|
||||
sourceLanguage: 'Deutsch',
|
||||
targetLanguage: 'Bisaya'
|
||||
},
|
||||
answerData: {
|
||||
type: 'transformation',
|
||||
correct: 'Moadto ko sa balay',
|
||||
alternatives: ['Mo-uli ko', 'Moadto ko sa among balay']
|
||||
},
|
||||
explanation: '"Moadto ko sa balay" und "Mo-uli ko" können beide "Ich gehe nach Hause" bedeuten.'
|
||||
},
|
||||
{
|
||||
exerciseTypeId: 4, // transformation
|
||||
title: 'Weg-Satz übersetzen 2',
|
||||
instruction: 'Übersetze den Satz ins Bisaya.',
|
||||
questionData: {
|
||||
type: 'transformation',
|
||||
text: 'Ich gehe in die Kirche',
|
||||
sourceLanguage: 'Deutsch',
|
||||
targetLanguage: 'Bisaya'
|
||||
},
|
||||
answerData: {
|
||||
type: 'transformation',
|
||||
correct: 'Moadto ko sa simbahan',
|
||||
alternatives: ['Adto ko sa simbahan', 'Moadto ko sa among simbahan']
|
||||
},
|
||||
explanation: '"simbahan" = Kirche.'
|
||||
}
|
||||
],
|
||||
|
||||
// Woche 1 - Wiederholung (Lektion 9)
|
||||
'Woche 1 - Wiederholung': [
|
||||
{
|
||||
@@ -971,7 +1270,10 @@ async function createBisayaCourseContent() {
|
||||
const replacePlaceholders = [
|
||||
'Woche 1 - Wiederholung',
|
||||
'Woche 1 - Vokabeltest',
|
||||
'Alltagsgespräche - Teil 1'
|
||||
'Alltagsgespräche - Teil 1',
|
||||
'Alltagsgespräche - Teil 2',
|
||||
'Haus & Familie',
|
||||
'Ort & Richtung'
|
||||
].includes(lesson.title);
|
||||
const existingCount = await VocabGrammarExercise.count({
|
||||
where: { lessonId: lesson.id }
|
||||
|
||||
@@ -13,7 +13,8 @@ import { setupWebSocket } from './utils/socket.js';
|
||||
import { syncDatabase } from './utils/syncDatabase.js';
|
||||
|
||||
// HTTP-Server für API (Port 2020, intern, über Apache-Proxy)
|
||||
const API_PORT = Number.parseInt(process.env.PORT || '2020', 10);
|
||||
const API_PORT = Number.parseInt(process.env.API_PORT || process.env.PORT || '2020', 10);
|
||||
const API_HOST = process.env.API_HOST || '127.0.0.1';
|
||||
const httpServer = http.createServer(app);
|
||||
// Socket.io wird nur auf HTTPS-Server bereitgestellt, nicht auf HTTP-Server
|
||||
// setupWebSocket(httpServer); // Entfernt: Socket.io nur über HTTPS
|
||||
@@ -25,6 +26,7 @@ const USE_TLS = process.env.SOCKET_IO_TLS === '1';
|
||||
const TLS_KEY_PATH = process.env.SOCKET_IO_TLS_KEY_PATH;
|
||||
const TLS_CERT_PATH = process.env.SOCKET_IO_TLS_CERT_PATH;
|
||||
const TLS_CA_PATH = process.env.SOCKET_IO_TLS_CA_PATH;
|
||||
const SOCKET_IO_HOST = process.env.SOCKET_IO_HOST || '0.0.0.0';
|
||||
|
||||
if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
|
||||
try {
|
||||
@@ -45,14 +47,14 @@ if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
|
||||
|
||||
syncDatabase().then(() => {
|
||||
// API-Server auf Port 2020 (intern, nur localhost)
|
||||
httpServer.listen(API_PORT, '127.0.0.1', () => {
|
||||
console.log(`[API] HTTP-Server läuft auf localhost:${API_PORT} (intern, über Apache-Proxy)`);
|
||||
httpServer.listen(API_PORT, API_HOST, () => {
|
||||
console.log(`[API] HTTP-Server läuft auf ${API_HOST}:${API_PORT}`);
|
||||
});
|
||||
|
||||
// Socket.io-Server auf Port 4443 (extern, direkt erreichbar)
|
||||
if (httpsServer) {
|
||||
httpsServer.listen(SOCKET_IO_PORT, '0.0.0.0', () => {
|
||||
console.log(`[Socket.io] HTTPS-Server läuft auf Port ${SOCKET_IO_PORT} (direkt erreichbar)`);
|
||||
httpsServer.listen(SOCKET_IO_PORT, SOCKET_IO_HOST, () => {
|
||||
console.log(`[Socket.io] HTTPS-Server läuft auf ${SOCKET_IO_HOST}:${SOCKET_IO_PORT}`);
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import amqp from 'amqplib/callback_api.js';
|
||||
import User from '../models/community/user.js';
|
||||
import Room from '../models/chat/room.js';
|
||||
|
||||
const RABBITMQ_URL = 'amqp://localhost';
|
||||
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
|
||||
const QUEUE = 'oneToOne_messages';
|
||||
|
||||
class ChatService {
|
||||
@@ -11,11 +13,37 @@ class ChatService {
|
||||
this.users = [];
|
||||
this.randomChats = [];
|
||||
this.oneToOneChats = [];
|
||||
this.channel = null;
|
||||
this.amqpAvailable = false;
|
||||
this.initRabbitMq();
|
||||
}
|
||||
|
||||
initRabbitMq() {
|
||||
amqp.connect(RABBITMQ_URL, (err, connection) => {
|
||||
if (err) throw err;
|
||||
connection.createChannel((err, channel) => {
|
||||
if (err) throw err;
|
||||
if (err) {
|
||||
console.warn(`[chatService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - fallback ohne Queue wird verwendet.`);
|
||||
return;
|
||||
}
|
||||
|
||||
connection.on('error', (connectionError) => {
|
||||
console.warn('[chatService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
|
||||
this.channel = null;
|
||||
this.amqpAvailable = false;
|
||||
});
|
||||
|
||||
connection.on('close', () => {
|
||||
console.warn('[chatService] RabbitMQ-Verbindung geschlossen.');
|
||||
this.channel = null;
|
||||
this.amqpAvailable = false;
|
||||
});
|
||||
|
||||
connection.createChannel((channelError, channel) => {
|
||||
if (channelError) {
|
||||
console.warn('[chatService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
|
||||
return;
|
||||
}
|
||||
this.channel = channel;
|
||||
this.amqpAvailable = true;
|
||||
channel.assertQueue(QUEUE, { durable: false });
|
||||
});
|
||||
});
|
||||
@@ -116,8 +144,14 @@ class ChatService {
|
||||
history: [messageBundle],
|
||||
});
|
||||
}
|
||||
if (this.channel) {
|
||||
if (this.channel && this.amqpAvailable) {
|
||||
try {
|
||||
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
|
||||
} catch (error) {
|
||||
console.warn('[chatService] sendToQueue fehlgeschlagen, Queue-Bridge vorübergehend deaktiviert:', error.message);
|
||||
this.channel = null;
|
||||
this.amqpAvailable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +182,66 @@ class ChatService {
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
async getRoomCreateOptions() {
|
||||
const { default: UserRightType } = await import('../models/type/user_right.js');
|
||||
const { default: InterestType } = await import('../models/type/interest.js');
|
||||
|
||||
const [rights, interests] = await Promise.all([
|
||||
UserRightType.findAll({
|
||||
attributes: ['id', 'title'],
|
||||
order: [['id', 'ASC']]
|
||||
}),
|
||||
InterestType.findAll({
|
||||
attributes: ['id', 'name'],
|
||||
order: [['id', 'ASC']]
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
rights: rights.map((r) => ({ id: r.id, title: r.title })),
|
||||
roomTypes: interests.map((i) => ({ id: i.id, name: i.name }))
|
||||
};
|
||||
}
|
||||
|
||||
async getOwnRooms(hashedUserId) {
|
||||
const user = await User.findOne({
|
||||
where: { hashedId: hashedUserId },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error('user_not_found');
|
||||
}
|
||||
|
||||
return Room.findAll({
|
||||
where: { ownerId: user.id },
|
||||
attributes: ['id', 'title', 'isPublic', 'roomTypeId', 'ownerId'],
|
||||
order: [['title', 'ASC']]
|
||||
});
|
||||
}
|
||||
|
||||
async deleteOwnRoom(hashedUserId, roomId) {
|
||||
const user = await User.findOne({
|
||||
where: { hashedId: hashedUserId },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error('user_not_found');
|
||||
}
|
||||
|
||||
const deleted = await Room.destroy({
|
||||
where: {
|
||||
id: roomId,
|
||||
ownerId: user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error('room_not_found_or_not_owner');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChatService();
|
||||
|
||||
@@ -2,7 +2,10 @@ import net from 'net';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DEFAULT_CONFIG = { host: 'localhost', port: 1235 };
|
||||
const DEFAULT_CONFIG = {
|
||||
host: process.env.CHAT_TCP_HOST || 'localhost',
|
||||
port: Number.parseInt(process.env.CHAT_TCP_PORT || '1235', 10),
|
||||
};
|
||||
|
||||
function loadBridgeConfig() {
|
||||
try {
|
||||
|
||||
@@ -389,6 +389,8 @@ class FalukantService extends BaseService {
|
||||
one: { min: 50, max: 5000 },
|
||||
all: { min: 400, max: 40000 }
|
||||
};
|
||||
static WOOING_PROGRESS_TARGET = 70;
|
||||
static WOOING_GIFT_COOLDOWN_MS = 30 * 60 * 1000;
|
||||
static HEALTH_ACTIVITIES = [
|
||||
{ tr: "barber", method: "healthBarber", cost: 10 },
|
||||
{ tr: "doctor", method: "healthDoctor", cost: 50 },
|
||||
@@ -2786,7 +2788,7 @@ class FalukantService extends BaseService {
|
||||
for (const c of character2s) {
|
||||
c.setDataValue('traits', traitsByChar[c.id] || []);
|
||||
}
|
||||
const relationships = relRows.map(r => {
|
||||
relationships = relRows.map(r => {
|
||||
const c2 = char2Map[r.character2Id];
|
||||
const type = typeMap[r.relationshipTypeId];
|
||||
return {
|
||||
@@ -2910,6 +2912,97 @@ class FalukantService extends BaseService {
|
||||
return { success: true, childCharacterId };
|
||||
}
|
||||
|
||||
async getPotentialHeirs(hashedUserId) {
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
if (!user) throw new Error('User not found');
|
||||
if (user.character?.id) return [];
|
||||
|
||||
const noncivilTitle = await TitleOfNobility.findOne({
|
||||
where: { labelTr: 'noncivil' },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (!noncivilTitle?.id) return [];
|
||||
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
|
||||
const mainRegionId = user.mainBranchRegionId || null;
|
||||
|
||||
const includes = [
|
||||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }
|
||||
];
|
||||
|
||||
const buildWhere = ({ withRegion = true, withYoungAge = true } = {}) => {
|
||||
const where = {
|
||||
userId: null,
|
||||
titleOfNobility: noncivilTitle.id,
|
||||
health: { [Op.gt]: 0 }
|
||||
};
|
||||
if (withRegion && mainRegionId) where.regionId = mainRegionId;
|
||||
if (withYoungAge) where.birthdate = { [Op.gte]: tenDaysAgo };
|
||||
return where;
|
||||
};
|
||||
|
||||
const loadCandidates = async (where) => FalukantCharacter.findAll({
|
||||
where,
|
||||
include: includes,
|
||||
attributes: ['id', 'birthdate', 'gender'],
|
||||
order: sequelize.random(),
|
||||
limit: 10
|
||||
});
|
||||
|
||||
let candidates = await loadCandidates(buildWhere({ withRegion: true, withYoungAge: true }));
|
||||
if (candidates.length === 0) {
|
||||
candidates = await loadCandidates(buildWhere({ withRegion: true, withYoungAge: false }));
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
candidates = await loadCandidates(buildWhere({ withRegion: false, withYoungAge: false }));
|
||||
}
|
||||
|
||||
return candidates.map(candidate => {
|
||||
const plain = candidate.get({ plain: true });
|
||||
return {
|
||||
...plain,
|
||||
age: plain.birthdate ? calcAge(plain.birthdate) : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async selectHeir(hashedUserId, heirId) {
|
||||
const parsedHeirId = Number(heirId);
|
||||
if (!Number.isInteger(parsedHeirId) || parsedHeirId < 1) {
|
||||
throw { status: 400, message: 'Invalid heirId' };
|
||||
}
|
||||
|
||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||
if (!user) throw new Error('User not found');
|
||||
if (user.character?.id) {
|
||||
throw { status: 409, message: 'User already has an active character' };
|
||||
}
|
||||
|
||||
const noncivilTitle = await TitleOfNobility.findOne({
|
||||
where: { labelTr: 'noncivil' },
|
||||
attributes: ['id']
|
||||
});
|
||||
if (!noncivilTitle?.id) throw new Error('Title "noncivil" not found');
|
||||
|
||||
const mainRegionId = user.mainBranchRegionId || null;
|
||||
const where = {
|
||||
id: parsedHeirId,
|
||||
userId: null,
|
||||
titleOfNobility: noncivilTitle.id,
|
||||
health: { [Op.gt]: 0 }
|
||||
};
|
||||
if (mainRegionId) where.regionId = mainRegionId;
|
||||
|
||||
const candidate = await FalukantCharacter.findOne({ where, attributes: ['id'] });
|
||||
if (!candidate) {
|
||||
throw { status: 404, message: 'Selected heir is not available' };
|
||||
}
|
||||
|
||||
await candidate.update({ userId: user.id });
|
||||
return { success: true, heirId: candidate.id };
|
||||
}
|
||||
|
||||
async getPossiblePartners(requestingCharacterId) {
|
||||
const proposals = await MarriageProposal.findAll({
|
||||
where: {
|
||||
@@ -3087,7 +3180,7 @@ class FalukantService extends BaseService {
|
||||
relatedChar.setDataValue('traits', traits);
|
||||
|
||||
// 3) Trait-IDs und Mood des relatedChar
|
||||
const relatedTraitIds = relatedChar.traits.map(t => t.id);
|
||||
const relatedTraitIds = (relatedChar.traits || []).map(t => t.id);
|
||||
const relatedMoodId = relatedChar.moodId;
|
||||
|
||||
// 4) Gifts laden – aber nur die passenden Moods und Traits als Unter-Arrays
|
||||
@@ -3112,17 +3205,41 @@ class FalukantService extends BaseService {
|
||||
|
||||
// 5) Rest wie gehabt: Kosten berechnen und zurückgeben
|
||||
const lowestTitleOfNobility = await TitleOfNobility.findOne({ order: [['id', 'ASC']] });
|
||||
return Promise.all(gifts.map(async gift => ({
|
||||
// Sicherstellen, dass wir eine gültige titleOfNobility haben (getFalukantUserByHashedId liefert nicht immer das Feld)
|
||||
let characterTitleOfNobility = myChar.titleOfNobility;
|
||||
if (characterTitleOfNobility == null && myChar.id) {
|
||||
const reloadChar = await FalukantCharacter.findOne({ where: { id: myChar.id }, attributes: ['titleOfNobility'] });
|
||||
characterTitleOfNobility = reloadChar?.titleOfNobility ?? lowestTitleOfNobility?.id ?? 1;
|
||||
}
|
||||
|
||||
// Filtere Gifts ohne gültigen 'value' (0 oder fehlend) — solche sollten in der DB korrigiert werden
|
||||
const validGifts = gifts.filter(g => Number(g.value) > 0);
|
||||
const skipped = gifts.length - validGifts.length;
|
||||
if (skipped > 0) {
|
||||
console.warn(`getGifts: skipped ${skipped} promotional gifts with invalid value`);
|
||||
for (const g of gifts) {
|
||||
if (!(Number(g.value) > 0)) console.warn(` skipped gift id=${g.id} name=${g.name} value=${g.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(validGifts.map(async gift => {
|
||||
const value = Number(gift.value);
|
||||
const cost = await this.getGiftCost(value, characterTitleOfNobility, lowestTitleOfNobility?.id ?? 1);
|
||||
return {
|
||||
id: gift.id,
|
||||
name: gift.name,
|
||||
cost: await this.getGiftCost(
|
||||
gift.value,
|
||||
myChar.titleOfNobility,
|
||||
lowestTitleOfNobility.id
|
||||
),
|
||||
moodsAffects: gift.promotionalgiftmoods, // nur Einträge mit relatedMoodId
|
||||
charactersAffects: gift.characterTraits // nur Einträge mit relatedTraitIds
|
||||
})));
|
||||
cost,
|
||||
// Frontend erwartet snake_case keys (mood_id / trait_id) in these arrays
|
||||
moodsAffects: (gift.promotionalgiftmoods || []).map(m => ({
|
||||
mood_id: m.moodId ?? m.mood_id ?? m.moodId,
|
||||
suitability: m.suitability
|
||||
})),
|
||||
charactersAffects: (gift.characterTraits || []).map(ct => ({
|
||||
trait_id: ct.traitId ?? ct.trait_id ?? ct.traitId,
|
||||
suitability: ct.suitability
|
||||
}))
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async getChildren(hashedUserId) {
|
||||
@@ -3196,19 +3313,23 @@ class FalukantService extends BaseService {
|
||||
order: [['createdAt', 'DESC']],
|
||||
limit: 1
|
||||
});
|
||||
if (lastGift && (lastGift.createdAt.getTime() + 3_600_000) > Date.now()) {
|
||||
const retryAt = new Date(lastGift.createdAt.getTime() + 3_600_000);
|
||||
if (lastGift && (lastGift.createdAt.getTime() + FalukantService.WOOING_GIFT_COOLDOWN_MS) > Date.now()) {
|
||||
const retryAt = new Date(lastGift.createdAt.getTime() + FalukantService.WOOING_GIFT_COOLDOWN_MS);
|
||||
const err = new PreconditionError('tooOften');
|
||||
err.meta = { retryAt: retryAt.toISOString() };
|
||||
throw err;
|
||||
}
|
||||
// prepare a safe trait filter: user.character.traits may be undefined
|
||||
const userTraitIds = Array.isArray(user.character?.traits) ? user.character.traits.map(t => t.id) : [];
|
||||
const traitWhere = userTraitIds.length ? { traitId: { [Op.in]: userTraitIds } } : { traitId: { [Op.in]: [-1] } };
|
||||
|
||||
const gift = await PromotionalGift.findOne({
|
||||
where: { id: giftId },
|
||||
include: [
|
||||
{
|
||||
model: PromotionalGiftCharacterTrait,
|
||||
as: 'characterTraits',
|
||||
where: { traitId: { [Op.in]: user.character.traits.map(t => t.id) } },
|
||||
where: traitWhere,
|
||||
required: false
|
||||
},
|
||||
{
|
||||
@@ -3230,12 +3351,14 @@ class FalukantService extends BaseService {
|
||||
if (user.money < cost) {
|
||||
throw new PreconditionError('insufficientFunds');
|
||||
}
|
||||
const traits = gift.characterTraits;
|
||||
const traits = Array.isArray(gift.characterTraits) ? gift.characterTraits : [];
|
||||
// Wenn keine passenden characterTraits gefunden wurden, behandeln wir das als neutralen Wert (0)
|
||||
// statt einen Fehler zu werfen. Das erlaubt das Versenden, auch wenn keine Trait-Übereinstimmung vorliegt.
|
||||
if (!traits.length) {
|
||||
throw new Error('noTraits');
|
||||
console.warn(`sendGift: no matching characterTraits for user id=${user.id} giftId=${giftId}`);
|
||||
}
|
||||
// Finde den höchsten Charakterwert (wie im Frontend)
|
||||
const highestCharacterValue = Math.max(...traits.map(ct => ct.suitability));
|
||||
// Finde den höchsten Charakterwert (wie im Frontend). Falls keine Traits vorhanden, 0.
|
||||
const highestCharacterValue = traits.length ? Math.max(...traits.map(ct => ct.suitability)) : 0;
|
||||
const moodRecord = gift.promotionalgiftmoods[0];
|
||||
if (!moodRecord) {
|
||||
throw new Error('noMoodData');
|
||||
@@ -3260,7 +3383,7 @@ class FalukantService extends BaseService {
|
||||
|
||||
async checkProposalProgress(relation) {
|
||||
const { nextStepProgress } = relation;
|
||||
if (nextStepProgress >= 100) {
|
||||
if (nextStepProgress >= FalukantService.WOOING_PROGRESS_TARGET) {
|
||||
const engagedStatus = await RelationshipType.findOne({ where: { tr: 'engaged' } });
|
||||
await relation.update({ nextStepProgress: 0, relationshipTypeId: engagedStatus.id });
|
||||
const user = await User.findOne({
|
||||
@@ -3279,8 +3402,12 @@ class FalukantService extends BaseService {
|
||||
}
|
||||
|
||||
async getGiftCost(value, titleOfNobility, lowestTitleOfNobility) {
|
||||
const titleLevel = titleOfNobility - lowestTitleOfNobility + 1;
|
||||
return Math.round(value * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100;
|
||||
const val = Number(value) || 0;
|
||||
const title = Number(titleOfNobility) || 1;
|
||||
const lowest = Number(lowestTitleOfNobility) || 1;
|
||||
const titleLevel = title - lowest + 1;
|
||||
const cost = Math.round(val * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100;
|
||||
return Number.isFinite(cost) ? cost : 0;
|
||||
}
|
||||
|
||||
async getTitlesOfNobility() {
|
||||
|
||||
@@ -2,38 +2,110 @@
|
||||
import { Server } from 'socket.io';
|
||||
import amqp from 'amqplib/callback_api.js';
|
||||
|
||||
const RABBITMQ_URL = 'amqp://localhost';
|
||||
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
|
||||
const QUEUE = 'chat_messages';
|
||||
const MAX_PENDING_MESSAGES = 500;
|
||||
|
||||
function routeMessage(io, message) {
|
||||
if (!message || typeof message !== 'object') return;
|
||||
|
||||
if (message.socketId) {
|
||||
io.to(message.socketId).emit('newMessage', message);
|
||||
return;
|
||||
}
|
||||
if (message.recipientSocketId) {
|
||||
io.to(message.recipientSocketId).emit('newMessage', message);
|
||||
return;
|
||||
}
|
||||
if (message.roomId) {
|
||||
io.to(String(message.roomId)).emit('newMessage', message);
|
||||
return;
|
||||
}
|
||||
if (message.room) {
|
||||
io.to(String(message.room)).emit('newMessage', message);
|
||||
return;
|
||||
}
|
||||
|
||||
io.emit('newMessage', message);
|
||||
}
|
||||
|
||||
export function setupWebSocket(server) {
|
||||
const io = new Server(server);
|
||||
let channel = null;
|
||||
let pendingMessages = [];
|
||||
|
||||
const flushPendingMessages = () => {
|
||||
if (!channel || pendingMessages.length === 0) return;
|
||||
const queued = pendingMessages;
|
||||
pendingMessages = [];
|
||||
for (const message of queued) {
|
||||
try {
|
||||
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
|
||||
} catch (err) {
|
||||
console.warn('[webSocketService] Flush fehlgeschlagen, Nachricht bleibt im Fallback:', err.message);
|
||||
pendingMessages.unshift(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
amqp.connect(RABBITMQ_URL, (err, connection) => {
|
||||
if (err) throw err;
|
||||
if (err) {
|
||||
console.warn(`[webSocketService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - WebSocket läuft ohne Queue-Bridge.`);
|
||||
return;
|
||||
}
|
||||
|
||||
connection.createChannel((err, channel) => {
|
||||
if (err) throw err;
|
||||
connection.on('error', (connectionError) => {
|
||||
console.warn('[webSocketService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
|
||||
channel = null;
|
||||
});
|
||||
|
||||
connection.on('close', () => {
|
||||
console.warn('[webSocketService] RabbitMQ-Verbindung geschlossen.');
|
||||
channel = null;
|
||||
});
|
||||
|
||||
connection.createChannel((channelError, createdChannel) => {
|
||||
if (channelError) {
|
||||
console.warn('[webSocketService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
channel = createdChannel;
|
||||
channel.assertQueue(QUEUE, { durable: false });
|
||||
channel.consume(QUEUE, (msg) => {
|
||||
if (!msg) return;
|
||||
const message = JSON.parse(msg.content.toString());
|
||||
routeMessage(io, message);
|
||||
}, { noAck: true });
|
||||
flushPendingMessages();
|
||||
});
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('Client connected via WebSocket');
|
||||
|
||||
// Konsumiert Nachrichten aus RabbitMQ und sendet sie an den WebSocket-Client
|
||||
channel.consume(QUEUE, (msg) => {
|
||||
const message = JSON.parse(msg.content.toString());
|
||||
io.emit('newMessage', message); // Broadcast an alle Clients
|
||||
}, { noAck: true });
|
||||
|
||||
// Empfangt eine Nachricht vom WebSocket-Client und sendet sie an die RabbitMQ-Warteschlange
|
||||
socket.on('newMessage', (message) => {
|
||||
if (channel) {
|
||||
try {
|
||||
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
|
||||
} catch (err) {
|
||||
console.warn('[webSocketService] sendToQueue fehlgeschlagen, nutze In-Memory-Fallback:', err.message);
|
||||
channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel) {
|
||||
pendingMessages.push(message);
|
||||
if (pendingMessages.length > MAX_PENDING_MESSAGES) {
|
||||
pendingMessages = pendingMessages.slice(-MAX_PENDING_MESSAGES);
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Client disconnected');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
37
backend/tools/dumpGiftsDebug.js
Normal file
37
backend/tools/dumpGiftsDebug.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
import PromotionalGift from '../models/falukant/type/promotional_gift.js';
|
||||
import PromotionalGiftMood from '../models/falukant/predefine/promotional_gift_mood.js';
|
||||
import PromotionalGiftCharacterTrait from '../models/falukant/predefine/promotional_gift_character_trait.js';
|
||||
|
||||
async function dump() {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('DB connected');
|
||||
|
||||
const gifts = await PromotionalGift.findAll({
|
||||
include: [
|
||||
{ model: PromotionalGiftMood, as: 'promotionalgiftmoods', attributes: ['moodId', 'suitability'], required: false },
|
||||
{ model: PromotionalGiftCharacterTrait, as: 'characterTraits', attributes: ['traitId', 'suitability'], required: false }
|
||||
]
|
||||
});
|
||||
|
||||
console.log(`found ${gifts.length} gifts`);
|
||||
for (const g of gifts) {
|
||||
console.log('---');
|
||||
console.log('id:', g.id, 'name:', g.name, 'raw value type:', typeof g.value, 'value:', g.value);
|
||||
try {
|
||||
const plain = g.get({ plain: true });
|
||||
console.log('plain value:', JSON.stringify(plain));
|
||||
} catch (e) {
|
||||
console.log('could not stringify plain', e);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('dump failed', err);
|
||||
process.exit(2);
|
||||
} finally {
|
||||
await sequelize.close();
|
||||
}
|
||||
}
|
||||
|
||||
dump();
|
||||
61
backend/tools/repairPromotionalGifts.js
Normal file
61
backend/tools/repairPromotionalGifts.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import PromotionalGift from '../models/falukant/type/promotional_gift.js';
|
||||
import { sequelize } from '../utils/sequelize.js';
|
||||
|
||||
// Mapping basierend auf initializeFalukantTypes.js
|
||||
const seedValues = {
|
||||
'Gold Coin': 100,
|
||||
'Silk Scarf': 50,
|
||||
'Exotic Perfume': 200,
|
||||
'Crystal Pendant': 150,
|
||||
'Leather Journal': 75,
|
||||
'Fine Wine': 120,
|
||||
'Artisan Chocolate': 40,
|
||||
'Pearl Necklace': 300,
|
||||
'Rare Painting': 500,
|
||||
'Silver Watch': 250,
|
||||
'Cat': 70,
|
||||
'Dog': 150,
|
||||
'Horse': 1000
|
||||
};
|
||||
|
||||
async function repair() {
|
||||
console.log('Repair promotional_gift values - starting');
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('DB connection ok');
|
||||
|
||||
// Liste aller problematischen Einträge
|
||||
const [rows] = await sequelize.query("SELECT id, name, value FROM falukant_type.promotional_gift WHERE value IS NULL OR value <= 0");
|
||||
if (!rows.length) {
|
||||
console.log('No invalid promotional_gift rows found. Nothing to do.');
|
||||
return process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${rows.length} invalid promotional_gift rows:`);
|
||||
for (const r of rows) console.log(` id=${r.id} name='${r.name}' value=${r.value}`);
|
||||
|
||||
// Update rows where we have a seed mapping
|
||||
let updated = 0;
|
||||
for (const r of rows) {
|
||||
const seed = seedValues[r.name];
|
||||
if (seed && Number(seed) > 0) {
|
||||
await PromotionalGift.update({ value: seed }, { where: { id: r.id } });
|
||||
console.log(` updated id=${r.id} name='${r.name}' -> value=${seed}`);
|
||||
updated++;
|
||||
} else {
|
||||
console.warn(` no seed value for id=${r.id} name='${r.name}' - skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Done. Updated ${updated} rows. Remaining invalid: `);
|
||||
const [left] = await sequelize.query("SELECT id, name, value FROM falukant_type.promotional_gift WHERE value IS NULL OR value <= 0");
|
||||
for (const l of left) console.log(` id=${l.id} name='${l.name}' value=${l.value}`);
|
||||
console.log('If any remain, inspect and adjust manually.');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Repair failed:', err);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
repair();
|
||||
20
backend/tools/simulate_getGiftCost.js
Normal file
20
backend/tools/simulate_getGiftCost.js
Normal file
@@ -0,0 +1,20 @@
|
||||
function getGiftCostLocal(value, titleOfNobility, lowestTitleOfNobility) {
|
||||
const val = Number(value) || 0;
|
||||
const title = Number(titleOfNobility) || 1;
|
||||
const lowest = Number(lowestTitleOfNobility) || 1;
|
||||
const titleLevel = title - lowest + 1;
|
||||
const cost = Math.round(val * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100;
|
||||
return Number.isFinite(cost) ? cost : 0;
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ giftValue: 100, title: 3, lowest: 1 },
|
||||
{ giftValue: '200', title: '2', lowest: '1' },
|
||||
{ giftValue: null, title: null, lowest: null },
|
||||
{ giftValue: undefined, title: undefined, lowest: undefined },
|
||||
{ giftValue: 'abc', title: 5, lowest: 1 }
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
console.log(`in=${JSON.stringify(c)} -> cost=${getGiftCostLocal(c.giftValue, c.title, c.lowest)}`);
|
||||
}
|
||||
38
backend/tools/testGiftCost.js
Normal file
38
backend/tools/testGiftCost.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// Kleine Testhilfe: extrahiere getGiftCost aus service-file via eval (schneller Smoke-test ohne DB)
|
||||
const svcPath = path.resolve(process.cwd(), 'services', 'falukantService.js');
|
||||
const src = readFileSync(svcPath, 'utf8');
|
||||
|
||||
// Extrahiere die getGiftCost-Funktion via Regex (vereinfachte Annahme)
|
||||
const re = /async getGiftCost\([\s\S]*?\n\s*}\n/;
|
||||
const match = src.match(re);
|
||||
if (!match) {
|
||||
console.error('getGiftCost function not found');
|
||||
process.exit(2);
|
||||
}
|
||||
const funcSrc = match[0];
|
||||
// Wrappe in Async-Function und erzeuge getGiftCost im lokalen Scope
|
||||
const wrapper = `(async () => { ${funcSrc}; return getGiftCost; })()`;
|
||||
// eslint-disable-next-line no-eval
|
||||
const getGiftCostPromise = eval(wrapper);
|
||||
let getGiftCost;
|
||||
getGiftCostPromise.then(f => { getGiftCost = f; runTests(); }).catch(e => { console.error('eval failed', e); process.exit(2); });
|
||||
|
||||
function runTests() {
|
||||
const cases = [
|
||||
{ value: 100, title: 3, lowest: 1 },
|
||||
{ value: '200', title: '2', lowest: '1' },
|
||||
{ value: null, title: null, lowest: null },
|
||||
{ value: 'abc', title: 5, lowest: 1 }
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
getGiftCost(c.value, c.title, c.lowest).then(out => {
|
||||
console.log(`in=${JSON.stringify(c)} -> cost=${out}`);
|
||||
}).catch(err => console.error('error calling getGiftCost', err));
|
||||
}
|
||||
}
|
||||
// Ende Patch
|
||||
@@ -35,7 +35,7 @@ export function setupWebSocket(server) {
|
||||
|
||||
export function getIo() {
|
||||
if (!io) {
|
||||
throw new Error('Socket.io ist nicht initialisiert!');
|
||||
return null;
|
||||
}
|
||||
return io;
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export function getUserSockets() {
|
||||
|
||||
export async function notifyUser(recipientHashedUserId, event, data) {
|
||||
const io = getIo();
|
||||
if (!io) {
|
||||
if (DEBUG_SOCKETS) console.warn('[socket.io] notifyUser übersprungen: Socket.io nicht initialisiert');
|
||||
return;
|
||||
}
|
||||
const userSockets = getUserSockets();
|
||||
try {
|
||||
const recipientUser = await baseService.getUserByHashedId(recipientHashedUserId);
|
||||
@@ -70,6 +74,10 @@ export async function notifyUser(recipientHashedUserId, event, data) {
|
||||
|
||||
export async function notifyAllUsers(event, data) {
|
||||
const io = getIo();
|
||||
if (!io) {
|
||||
if (DEBUG_SOCKETS) console.warn('[socket.io] notifyAllUsers übersprungen: Socket.io nicht initialisiert');
|
||||
return;
|
||||
}
|
||||
const userSockets = getUserSockets();
|
||||
|
||||
try {
|
||||
|
||||
230
docs/UI_REDESIGN_PLAN.md
Normal file
230
docs/UI_REDESIGN_PLAN.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# UI Redesign Plan
|
||||
|
||||
## Status
|
||||
|
||||
- Prioritaet 1 ist umgesetzt.
|
||||
- Prioritaet 2 ist umgesetzt.
|
||||
- Phase 3 ist abgeschlossen.
|
||||
- Phase 4 ist abgeschlossen.
|
||||
- Phase 5 ist abgeschlossen.
|
||||
- Das Redesign ist damit insgesamt abgeschlossen.
|
||||
- Optionaler technischer Nachlauf:
|
||||
- weiterer Performance-Feinschliff rund um den separaten three-Chunk
|
||||
|
||||
## Ziel
|
||||
|
||||
Das Frontend von YourPart soll visuell und strukturell modernisiert werden, ohne die bestehende Funktionsbreite zu verlieren. Der Fokus liegt auf einem klareren Designsystem, besserer Informationshierarchie, konsistenter Navigation, responsiver Nutzung und einer deutlich hochwertigeren Wahrnehmung auf Startseite, Community-Bereichen und Spiele-/Lernseiten.
|
||||
|
||||
## Ausgangslage
|
||||
|
||||
Aktueller Stand aus dem Code:
|
||||
|
||||
- Globale Styles in `frontend/src/assets/styles.scss` nutzen ein sehr einfaches Basisset mit `Arial`, wenigen Tokens und kaum skalierbarer Designsystem-Logik.
|
||||
- `AppHeader.vue`, `AppNavigation.vue` und `AppFooter.vue` sind funktional, aber visuell eher wie ein klassisches Webportal aufgebaut.
|
||||
- Farben, Abstaende, Border-Radius, Buttons und Typografie wirken nicht konsistent genug fuer ein modernes Produktbild.
|
||||
- Navigation und Fensterleiste sind stark desktop-zentriert und sollten auf mobile Nutzung und klare Priorisierung neu gedacht werden.
|
||||
- Die Landing- und Content-Bereiche haben unterschiedliche visuelle Sprachen statt eines durchgaengigen Systems.
|
||||
|
||||
## Ziele des Redesigns
|
||||
|
||||
- Moderner, eigenstaendiger Look statt generischer Standard-UI.
|
||||
- Einheitliches Designsystem mit Tokens fuer Farben, Typografie, Spacing, Schatten, Radius und States.
|
||||
- Saubere responsive Struktur fuer Desktop, Tablet und Mobile.
|
||||
- Bessere Orientierung in Community, Blog, Vokabeltrainer und Falukant.
|
||||
- Hoeherer wahrgenommener Qualitaetsstandard bei gleicher oder besserer Performance.
|
||||
- Reduktion visueller Altlasten, Inline-Anmutung und inkonsistenter Bedienelemente.
|
||||
|
||||
## Nicht-Ziele
|
||||
|
||||
- Kein kompletter Funktionsumbau im ersten Schritt.
|
||||
- Keine grossflaechige Backend-Aenderung.
|
||||
- Kein unkontrolliertes Ersetzen aller bestehenden Komponenten auf einmal.
|
||||
|
||||
## Prioritaet 1: Fundament schaffen
|
||||
|
||||
### 1. Designsystem definieren
|
||||
|
||||
- Neue Design-Tokens in einer zentralen Schicht aufbauen:
|
||||
- Farben mit klarer Primar-, Sekundar-, Surface- und Statuslogik.
|
||||
- Typografiesystem mit moderner Schriftfamilie, Skalierung und konsistenten Headline-/Body-Stilen.
|
||||
- Spacing-System, Radius-System und Schatten-System standardisieren.
|
||||
- Zustandsdefinitionen fuer Hover, Focus, Active, Disabled.
|
||||
|
||||
### 2. Globale Styling-Basis modernisieren
|
||||
|
||||
- `frontend/src/assets/styles.scss` in ein wartbares Fundament ueberfuehren.
|
||||
- Einheitliche Defaults fuer `body`, `a`, `button`, Inputs, Listen, Headlines und Fokus-Stati.
|
||||
- Gemeinsame Utility-Klassen nur dort einfuehren, wo sie echten Wiederverwendungswert haben.
|
||||
- Barrierefreiheit von Kontrasten und Fokus-Indikatoren direkt mitdenken.
|
||||
|
||||
### 3. Layout-Architektur festziehen
|
||||
|
||||
- Feste Regeln fuer Header, Navigation, Content-Flaeche, Footer und Dialog-Layer definieren.
|
||||
- Maximale Content-Breiten und Raster fuer Marketing-, Dashboard- und Formularseiten festlegen.
|
||||
- Dialoge, Window-Bar und Overlay-Logik visuell harmonisieren.
|
||||
|
||||
## Prioritaet 2: Kernnavigation neu gestalten
|
||||
|
||||
### 4. Header ueberarbeiten
|
||||
|
||||
- `frontend/src/components/AppHeader.vue` neu strukturieren.
|
||||
- Logo, Produktidentitaet, Systemstatus und optionaler Utility-Bereich klarer anordnen.
|
||||
- Die derzeitige Werbeflaeche kritisch pruefen und nur behalten, wenn sie produktseitig wirklich gewollt ist.
|
||||
- Statusindikatoren moderner, diskreter und semantisch staerker gestalten.
|
||||
|
||||
### 5. Hauptnavigation neu denken
|
||||
|
||||
- `frontend/src/components/AppNavigation.vue` vereinfachen und priorisieren.
|
||||
- Mehrstufige Menues visuell und interaction-seitig robuster aufbauen.
|
||||
- Mobile Navigation als eigenes Muster mit Drawer/Sheet oder kompaktem Menuekonzept planen.
|
||||
- Wichtige Bereiche zuerst sichtbar machen, seltene Admin- oder Tiefenfunktionen entlasten.
|
||||
- Forum, Freunde und Vokabeltrainer-Untermenues gestalterisch besser lesbar machen.
|
||||
|
||||
### 6. Footer und Fensterleiste modernisieren
|
||||
|
||||
- `frontend/src/components/AppFooter.vue` als funktionale Systemleiste neu fassen.
|
||||
- Geoeffnete Dialoge, statische Links und Systemaktionen visuell sauber trennen.
|
||||
- Footer nicht nur als Restflaeche behandeln, sondern in das Gesamtsystem integrieren.
|
||||
|
||||
## Prioritaet 3: Visuelle Produktidentitaet schaerfen
|
||||
|
||||
### 7. Startseite neu positionieren
|
||||
|
||||
- Startseite in zwei Modi denken:
|
||||
- Nicht eingeloggt: klare Landingpage mit Nutzen, Produktbereichen, Vertrauen und Handlungsaufforderungen.
|
||||
- Eingeloggt: dashboard-artiger Einstieg mit hoher Informationsdichte, aber klarer Ordnung.
|
||||
- Die bisherige Startseite braucht eine deutlich staerkere visuelle Hierarchie und bessere Inhaltsblöcke.
|
||||
|
||||
### 8. Einheitliche Oberflaechen fuer Kernbereiche
|
||||
|
||||
- Community-/Social-Bereiche: ruhiger, strukturierter, content-orientierter.
|
||||
- Blogs: lesefreundlicher, mehr Editorial-Charakter.
|
||||
- Vokabeltrainer: lernorientiert, klar, fokussiert.
|
||||
- Falukant: spielweltbezogen, aber nicht altmodisch; eigene Atmosphaere innerhalb des Systems.
|
||||
- Minispiele: kompakter, energischer, aber im selben visuellen Dach.
|
||||
|
||||
### 9. Komponentenbibliothek aufraeumen
|
||||
|
||||
- Buttons, Tabs, Cards, Inputs, Dialogtitel, Listen, Badges und Status-Chips vereinheitlichen.
|
||||
- Bestehende Komponenten auf Dopplungen und Stilbrueche pruefen.
|
||||
- Komponenten nach Rollen statt nach Einzelseiten standardisieren.
|
||||
|
||||
## Prioritaet 4: Responsivitaet und UX-Qualitaet
|
||||
|
||||
### 10. Mobile First nachziehen
|
||||
|
||||
- Brechpunkte und Layout-Verhalten klar festlegen.
|
||||
- Navigation, Dialoge, Tabellen, Formulare und Dashboard-Bloecke fuer kleine Screens neu validieren.
|
||||
- Hover-abhaengige Interaktionen fuer Touch-Nutzung absichern.
|
||||
|
||||
### 11. Bewegungen und visuelles Feedback
|
||||
|
||||
- Subtile, hochwertige Motion fuer Menues, Dialoge, Hover-Stati und Seitenwechsel einbauen.
|
||||
- Keine generischen Effekte; Animationen muessen Orientierung verbessern.
|
||||
|
||||
### 12. Accessibility und Lesbarkeit
|
||||
|
||||
- Tastaturbedienung in Navigation und Dialogen pruefen.
|
||||
- Farbkontraste, Fokus-Ringe und Textgroessen ueberarbeiten.
|
||||
- Inhaltsstruktur mit klaren Headline-Ebenen und besserer Lesefuehrung absichern.
|
||||
|
||||
## Umsetzung in Phasen
|
||||
|
||||
### Phase 1: Audit und visuelle Richtung
|
||||
|
||||
- Bestehende Screens inventarisieren.
|
||||
- Wiederkehrende UI-Muster erfassen.
|
||||
- Zielrichtung fuer Markenbild definieren: warm, modern, eigenstaendig, leicht spielerisch.
|
||||
- Moodboard bzw. 2-3 Stilrouten festlegen.
|
||||
|
||||
### Phase 2: Designsystem und Shell
|
||||
|
||||
- Tokens und globale Styles erstellen.
|
||||
- Header, Navigation, Footer und Content-Layout neu bauen.
|
||||
- Dialog- und Formular-Basis angleichen.
|
||||
|
||||
### Phase 3: Startseite und Kernseiten
|
||||
|
||||
- Home, Blog-Liste, Blog-Detail und ein zentraler Community-Bereich ueberarbeiten.
|
||||
- Danach Vokabeltrainer-Landing/Kernseiten.
|
||||
- Danach Falukant- und Minigame-Einstiege.
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- umgesetzt fuer Home, Blogs, zentrale Social-/Community-Flaechen, Vokabeltrainer-Kernseiten, Kalender und zentrale Falukant-Einstiege
|
||||
- Restpunkte in tieferen Vokabel-, Minigame-, Settings- und Admin-Ansichten sind nachgezogen
|
||||
|
||||
### Phase 4: Tiefere Produktbereiche
|
||||
|
||||
- Sekundaere Ansichten und Admin-Bereiche nachziehen.
|
||||
- Visuelle Altlasten in Randbereichen bereinigen.
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- zentrale Produktbereiche, tiefere Community-/Vokabel-/Kalender-Ansichten sowie zentrale Falukant-Einstiege und Dialog-Basis sind modernisiert
|
||||
- verbleibende Rand- und Spezialansichten aus Settings, Admin und Minigames sind visuell angeglichen
|
||||
|
||||
### Phase 5: QA und Verfeinerung
|
||||
|
||||
- Responsive Review.
|
||||
- Accessibility Review.
|
||||
- Performance-Pruefung auf unnötige visuelle Last.
|
||||
- Konsistenz-Check ueber das gesamte Produkt.
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- globale Bewegungsreduktion, verbesserte Fokusfuehrung und Tastaturzugang in der Hauptnavigation umgesetzt
|
||||
- Build-Chunking verbessert; Haupt-Chunk bereits reduziert
|
||||
- 3D-Runtime in Character3D auf Lazy-Loading umgestellt; verbleibende Warnung betrifft den separaten three-Chunk selbst
|
||||
|
||||
## Empfohlene technische Arbeitspakete
|
||||
|
||||
### Paket A: Design Tokens
|
||||
|
||||
- Neue CSS-Variablenstruktur aufbauen.
|
||||
- Alte Farbwerte und Ad-hoc-Stile schrittweise ersetzen.
|
||||
|
||||
### Paket B: App Shell
|
||||
|
||||
- `AppHeader.vue`
|
||||
- `AppNavigation.vue`
|
||||
- `AppFooter.vue`
|
||||
- `App.vue`
|
||||
- `frontend/src/assets/styles.scss`
|
||||
|
||||
### Paket C: Content-Komponenten
|
||||
|
||||
- Gemeinsame Card-, Section-, Button- und Dialogmuster erstellen oder konsolidieren.
|
||||
- Stark genutzte Widgets und Listen zuerst migrieren.
|
||||
|
||||
### Paket D: Seitenweise Migration
|
||||
|
||||
- Startseite
|
||||
- Blogs
|
||||
- Community
|
||||
- Vokabeltrainer
|
||||
- Falukant
|
||||
- Minispiele
|
||||
|
||||
## Reihenfolge fuer die Umsetzung
|
||||
|
||||
1. Designrichtung und Token-System festlegen.
|
||||
2. App-Shell modernisieren.
|
||||
3. Startseite und oeffentliche Einstiege erneuern.
|
||||
4. Kern-Komponentenbibliothek vereinheitlichen.
|
||||
5. Hauptbereiche seitenweise migrieren.
|
||||
6. Mobile, Accessibility und Feinschliff abschliessen.
|
||||
|
||||
## Risiken
|
||||
|
||||
- Ein rein visuelles Redesign ohne Systembasis fuehrt wieder zu inkonsistenten Einzelpatches.
|
||||
- Navigation und Dialog-Logik sind funktional verflochten; dort braucht es saubere schrittweise Migration.
|
||||
- Falukant und Community haben unterschiedliche Produktcharaktere; die Klammer muss bewusst gestaltet werden.
|
||||
- Zu viele parallele Einzelumbauten wuerden das Styling kurzfristig uneinheitlicher machen.
|
||||
|
||||
## Empfehlung
|
||||
|
||||
Das Redesign sollte nicht als einzelne Seitenkosmetik umgesetzt werden, sondern als kontrollierte Migration mit Designsystem zuerst. Der erste konkrete Umsetzungsschritt sollte daher ein kleines, aber verbindliches UI-Fundament sein: neue Tokens, neue Typografie, neue App-Shell und eine modernisierte Startseite. Danach koennen die restlichen Bereiche kontrolliert nachgezogen werden.
|
||||
389
docs/USABILITY_AUDIT_U1.md
Normal file
389
docs/USABILITY_AUDIT_U1.md
Normal file
@@ -0,0 +1,389 @@
|
||||
# UX Audit U1
|
||||
|
||||
## Ziel
|
||||
|
||||
Dieser Audit bildet Phase U1 des Bedienbarkeitskonzepts ab. Er dient als priorisierte Arbeitsgrundlage fuer die eigentliche UX-Ueberarbeitung.
|
||||
|
||||
Untersucht wurden:
|
||||
|
||||
- Shell und Navigation
|
||||
- Einstieg ohne Login
|
||||
- Registrierung/Login
|
||||
- Forum
|
||||
- Vokabeltrainer
|
||||
- Falukant
|
||||
- Admin
|
||||
- Match3/Minigames
|
||||
|
||||
## Bewertungslogik
|
||||
|
||||
- `P1`: blockiert Kernnutzung oder fuehrt sehr leicht zu Fehlbedienung
|
||||
- `P2`: verlangsamt oder verkompliziert Kernnutzung merklich
|
||||
- `P3`: Konsistenz-, Lesbarkeits- oder Komfortproblem
|
||||
- `P4`: Feinschliff
|
||||
|
||||
## Gepruefte Hauptaufgaben
|
||||
|
||||
1. Einloggen und Einstieg verstehen
|
||||
2. Registrieren
|
||||
3. Forum finden, Thema erstellen und lesen
|
||||
4. Vokabelsprache finden, anlegen, abonnieren und lernen
|
||||
5. Falukant-Status erfassen und Folgeaktion auswaehlen
|
||||
6. Admin-Nutzer oder Match3-Daten bearbeiten
|
||||
7. Match3 starten, pausieren und Kampagnenstatus verstehen
|
||||
|
||||
## Ergebnisuebersicht
|
||||
|
||||
- P1: 4 Punkte
|
||||
- P2: 11 Punkte
|
||||
- P3: 13 Punkte
|
||||
- P4: 6 Punkte
|
||||
|
||||
## P1-Probleme
|
||||
|
||||
### P1-1: Historische innere Scrollkonzepte in Teilbereichen
|
||||
|
||||
Bereiche:
|
||||
|
||||
- Falukant
|
||||
- Admin
|
||||
- Minigames
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- Mehrere Views arbeiten weiterhin mit eigenen `contenthidden/contentscroll`-Strukturen innerhalb des bereits scrollbaren App-Contents.
|
||||
- Das fuehrt zu falschen Sticky-Bezuegen, abgeschnittenen Bereichen und inkonsistenter Scrolllogik.
|
||||
|
||||
Risiko:
|
||||
|
||||
- Nutzer verlieren Orientierung.
|
||||
- statische Leisten oder Footer/Header verhalten sich unerwartet.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- alle View-internen Scrollcontainer systematisch inventarisieren
|
||||
- entscheiden, welche Bereiche echte lokale Scrollflaechen brauchen und welche komplett auf die Shell-Scrolllogik gehen
|
||||
|
||||
### P1-2: Fehler- und Erfolgsfeedback ist nicht konsistent genug
|
||||
|
||||
Bereiche:
|
||||
|
||||
- Auth
|
||||
- Settings
|
||||
- Admin
|
||||
- Vokabeltrainer
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- Mischung aus `alert`, MessageDialog, ErrorDialog, DialogWidget-internem Feedback und stillen `console.error`-Pfaden.
|
||||
- Beispiel: [RegisterDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/auth/RegisterDialog.vue) nutzt fehleranfaellig `errrorDialog` statt eines klar vereinheitlichten Feedbackpfads.
|
||||
|
||||
Risiko:
|
||||
|
||||
- Nutzer verstehen nicht sicher, ob eine Aktion fehlgeschlagen ist oder erfolgreich war.
|
||||
- Fehler werden in einzelnen Flows inkonsistent oder gar nicht sichtbar.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- gemeinsames Feedbacksystem definieren
|
||||
- `success`, `warning`, `error`, `info`, `loading` als einheitliche Muster durchziehen
|
||||
|
||||
### P1-3: Formvalidierung erfolgt oft zu spaet oder zu unsichtbar
|
||||
|
||||
Bereiche:
|
||||
|
||||
- Registrierung
|
||||
- Account-Settings
|
||||
- Admin
|
||||
- Falukant-Formulare
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- viele Formulare validieren erst beim Absenden
|
||||
- Feldfehler sitzen selten direkt am Eingabepunkt
|
||||
- Pflichtlogik ist nicht durchgaengig erkennbar
|
||||
|
||||
Risiko:
|
||||
|
||||
- hohe Reibung beim Ausfuellen
|
||||
- Wiederholschleifen und Frust bei laengeren Formularen
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Validierung naeher ans Feld bringen
|
||||
- Pflichtfelder, Eingabeformat und Fehltext systematisch sichtbar machen
|
||||
|
||||
### P1-4: Komplexe Arbeitsflaechen haben zu wenig gefuehrte Primaeraktionen
|
||||
|
||||
Bereiche:
|
||||
|
||||
- Falukant
|
||||
- Admin Match3
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- Fachflaechen zeigen viele Optionen gleichzeitig, ohne klare Priorisierung.
|
||||
- Beispiel: [MinigamesView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/admin/MinigamesView.vue) ist funktionsreich, aber als Bearbeitungsfluss kaum gefuehrt.
|
||||
|
||||
Risiko:
|
||||
|
||||
- Nutzer verstehen den naechsten sinnvollen Schritt nicht.
|
||||
- Bedienfehler in fachlich dichten Bereichen steigen.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- pro komplexer View den eigentlichen Arbeitsfluss definieren
|
||||
- Primaeraktionen, Sekundaeraktionen und Expertenaktionen sichtbarer trennen
|
||||
|
||||
## P2-Probleme
|
||||
|
||||
### P2-1: Hauptnavigation ist funktional, aber noch nicht ausreichend auf Aufgaben priorisiert
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- Navigation ist technisch konsistenter als vorher, aber die inhaltliche Priorisierung der Menuepunkte ist noch stark historisch gewachsen.
|
||||
- Der Unterschied zwischen Kernzielen und Tiefenfunktionen ist nicht deutlich genug.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Menueaufbau gegen echte Nutzungsszenarien pruefen
|
||||
- seltene Spezialpunkte staerker entlasten
|
||||
|
||||
### P2-2: Login-Einstieg ist reich an Inhalt, aber nicht maximal fokussiert
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- [NoLoginView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/home/NoLoginView.vue) erzaehlt Produkt und Zugang parallel.
|
||||
- Fuer Erstnutzer ist die Seite informativ, aber die eigentliche Primaerhandlung konkurriert mit vielen Begleitinhalten.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Zugangspfad noch klarer vom Story-Bereich trennen
|
||||
- Login, Registrierung und Passwort-Reset als zusammenhaengenden Entscheidungsraum denken
|
||||
|
||||
### P2-3: Registrierung ist bedienbar, aber noch nicht robust genug gefuehrt
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- [RegisterDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/auth/RegisterDialog.vue) ist funktional, aber nutzt wenig Hilfetext, kaum Inline-Validierung und schwer lesbare Fehlerpfade.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Feldgruppen strukturieren
|
||||
- Passwortlogik und Sprachwahl klarer erklaeren
|
||||
- Fehler am Feld statt nur global rueckmelden
|
||||
|
||||
### P2-4: Forum hat guten Einstieg, aber zu wenig Orientierung im Schreiben-und-Lesen-Wechsel
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- [ForumView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/social/ForumView.vue) mischt Themenliste und Neuerstellung in einem einfachen Toggle.
|
||||
- Es fehlt eine staerkere Fuehrung, wann man lesen und wann man schreiben soll.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Schreibmodus staerker vom Lesemodus absetzen
|
||||
- Entwurf, Abbruch und Rueckkehr klarer machen
|
||||
|
||||
### P2-5: Vokabeltrainer ist funktional breit, aber als Lernpfad noch nicht klar genug
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- Anlegen, Abonnieren, Kapitel, Kurs, Lektion und Uebung sind einzeln verbessert, bilden aber noch keinen vollkommen klaren End-to-End-Lernpfad.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Informationsarchitektur entlang von:
|
||||
- entdecken
|
||||
- beitreten
|
||||
- lernen
|
||||
- ueben
|
||||
- bearbeiten
|
||||
|
||||
### P2-6: Falukant ist visuell stark, aber in taeglichen Routineablaeufen noch nicht effizient genug
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- Statusleiste und Einstiege sind besser, aber Arbeitsablaeufe ueber mehrere Unterseiten bleiben kognitiv schwer.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Top-5-Routinen definieren
|
||||
- dafuer direkte Folgeaktionen aus Status und Uebersichten anbieten
|
||||
|
||||
### P2-7: Admin-Flaechen haben zu wenig gemeinsame Bedienlogik
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- einzelne Admin-Seiten sind moderner, aber Such-, Editier- und Speichermuster unterscheiden sich weiterhin.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- gemeinsames Admin-Muster fuer Suche, Detailansicht, Editieren und Speichern definieren
|
||||
|
||||
### P2-8: Match3 hat gute Spieloberflaeche, aber Meta-Interaktion ist noch zu verstreut
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- Spielstatus, Levelbeschreibung, Statistiken und Steuerung konkurrieren um Aufmerksamkeit.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- klare Prioritaet:
|
||||
- Spielbrett
|
||||
- aktuelles Ziel
|
||||
- verbleibende Zuege
|
||||
- Meta-Infos nur sekundär
|
||||
|
||||
### P2-9: Rueckwege sind nicht ueberall gleich gut sichtbar
|
||||
|
||||
Bereiche:
|
||||
|
||||
- Vokabel-Unterseiten
|
||||
- Forum-Themen
|
||||
- Falukant-Unterseiten
|
||||
- Admin-Details
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- gemeinsames Rueckwegmuster definieren
|
||||
- nicht nur einzelne Buttons, sondern konsistente Bereichsorientierung
|
||||
|
||||
### P2-10: Leere Zustände sind nicht systematisch genug
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- teilweise vorhanden, aber sehr uneinheitlich in Ton, Handlungsangebot und Sichtbarkeit.
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- Standard fuer:
|
||||
- keine Daten
|
||||
- keine Treffer
|
||||
- noch nicht gestartet
|
||||
- keine Berechtigung
|
||||
|
||||
### P2-11: Mobile Nutzbarkeit ist verbessert, aber nicht abschließend entlang echter Kernaufgaben geprüft
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- echter Geräte-/Viewport-Durchgang entlang der Kernszenarien
|
||||
|
||||
## P3-Probleme
|
||||
|
||||
### P3-1: Button-Semantik und Farblogik sind noch nicht vollkommen systematisch
|
||||
|
||||
Beobachtung:
|
||||
|
||||
- globale Buttons sind modernisiert, lokale Altvarianten bestehen weiter.
|
||||
|
||||
### P3-2: Teilweise alte Feld- und Listenmuster in Spezialbereichen
|
||||
|
||||
### P3-3: Headline-Hierarchien sind nicht in allen Ansichten gleich klar
|
||||
|
||||
### P3-4: Dialoginhalte wirken je nach Bereich unterschiedlich dicht
|
||||
|
||||
### P3-5: Tabellen haben uneinheitliche Leselogik und Aktionsplatzierung
|
||||
|
||||
### P3-6: In einigen Bereichen ist unklar, welche Aktion primaer und welche optional ist
|
||||
|
||||
### P3-7: Beta-/Systemhinweise koennen in Teilen ruhiger und weniger redundant werden
|
||||
|
||||
### P3-8: Einige Views nutzen sehr lange vertikale Inhaltsflaechen ohne Zwischenanker
|
||||
|
||||
### P3-9: Inline-Hilfen und Tooltips sind in komplexen Bereichen noch unterentwickelt
|
||||
|
||||
### P3-10: Touch-/Hover-Verhalten ist nicht in allen Spezialviews gleich robust
|
||||
|
||||
### P3-11: Message- und Error-Wording ist noch nicht konsistent
|
||||
|
||||
### P3-12: Manche Dialoge und Formulare sind auf Desktop gut, aber auf enger Breite nur ausreichend
|
||||
|
||||
### P3-13: Such- und Filterbereiche koennten staerker standardisiert werden
|
||||
|
||||
## P4-Punkte
|
||||
|
||||
### P4-1: Mikrointeraktionen in Karten, Listen und Toolbars weiter harmonisieren
|
||||
|
||||
### P4-2: Badge- und Statusdarstellungen semantisch weiter schaerfen
|
||||
|
||||
### P4-3: Fokus- und Hover-Zustaende in Spezialkomponenten weiter angleichen
|
||||
|
||||
### P4-4: Editorbereiche visuell und bedienlogisch weiter vereinheitlichen
|
||||
|
||||
### P4-5: Leichte Textkuerzungen fuer schnellere Scanbarkeit in Hero-Bereichen
|
||||
|
||||
### P4-6: Weitere Feinarbeit an Footer/Fensterleiste in langen Nutzungssessions
|
||||
|
||||
## Bereichsspezifische Kurzbewertung
|
||||
|
||||
### Shell und Navigation
|
||||
|
||||
- deutlich verbessert
|
||||
- noch offen: inhaltliche Priorisierung, Rueckwege, Endabnahme kleiner Screens
|
||||
|
||||
### Einstieg ohne Login
|
||||
|
||||
- stark verbessert
|
||||
- noch offen: Zugangspfad fokussierter gegen Story-Inhalt absetzen
|
||||
|
||||
### Registrierung/Login
|
||||
|
||||
- funktional ok
|
||||
- UX-seitig noch zu wenig gefuehrt und rueckmeldearm
|
||||
|
||||
### Forum
|
||||
|
||||
- guter Ueberblick, aber Schreib-/Lesefluss noch nicht ideal getrennt
|
||||
|
||||
### Vokabeltrainer
|
||||
|
||||
- optisch konsistent, aber noch kein vollkommen klarer End-to-End-Lernpfad
|
||||
|
||||
### Falukant
|
||||
|
||||
- visuell stark, fachlich noch der anspruchsvollste Bedienbereich
|
||||
|
||||
### Admin
|
||||
|
||||
- einzelne Ansichten verbessert, gemeinsame Admin-Bedienlogik fehlt noch
|
||||
|
||||
### Minigames
|
||||
|
||||
- Match3 solide, aber Status-/Metaebene kann bedienlogisch weiter fokussiert werden
|
||||
|
||||
## Priorisierte Umsetzung nach U1
|
||||
|
||||
### Paket U1-A
|
||||
|
||||
- Feedbacksystem vereinheitlichen
|
||||
- Formularvalidierung sichtbar machen
|
||||
- Scroll- und Sticky-Logik historischer Sonderfaelle bereinigen
|
||||
|
||||
### Paket U1-B
|
||||
|
||||
- Navigation und Rueckwege nach Aufgaben priorisieren
|
||||
- Leere Zustände und Systemzustände standardisieren
|
||||
|
||||
### Paket U1-C
|
||||
|
||||
- Vokabeltrainer-Lernpfad schärfen
|
||||
- Falukant-Routinen entschlacken
|
||||
- Admin-Bedienmuster vereinheitlichen
|
||||
|
||||
### Paket U1-D
|
||||
|
||||
- Mobile Kernaufgaben-Endabnahme
|
||||
- Konsistenz-Feinschliff auf P3/P4-Niveau
|
||||
|
||||
## Fazit
|
||||
|
||||
Die App ist gestalterisch deutlich weiter als vor dem Redesign, aber bedienlogisch noch nicht auf demselben Reifegrad. Die größten UX-Hebel liegen nicht mehr in Farben oder Layout, sondern in:
|
||||
|
||||
- konsistentem Feedback
|
||||
- klareren Aufgabenflüssen
|
||||
- sichtbarerer Validierung
|
||||
- entschlackten Fachbereichen
|
||||
- sauberer Priorisierung von Aktionen
|
||||
|
||||
Phase U1 ist damit abgeschlossen. Die naechste sinnvolle Arbeitsphase ist `U2: Shell, Navigation und Feedback`.
|
||||
401
docs/USABILITY_CONCEPT.md
Normal file
401
docs/USABILITY_CONCEPT.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# Bedienbarkeitskonzept
|
||||
|
||||
## Ziel
|
||||
|
||||
Die Bedienbarkeit von YourPart soll systematisch verbessert werden, ohne die vorhandene Funktionsbreite oder den neuen UI-Stand wieder aufzubrechen. Der Fokus liegt auf Orientierung, Vorhersagbarkeit, Aufgabenfluss, Fehlertoleranz und effizienter Nutzung auf Desktop und Mobile.
|
||||
|
||||
Das Dokument ist bewusst als Arbeitsgrundlage aufgebaut:
|
||||
|
||||
- Was genau verbessert werden soll
|
||||
- nach welchen Prinzipien entschieden wird
|
||||
- in welcher Reihenfolge gearbeitet wird
|
||||
- woran ein Punkt als erledigt gilt
|
||||
|
||||
## Leitprinzipien
|
||||
|
||||
### 1. Weniger Reibung
|
||||
|
||||
- Haeufige Aufgaben muessen mit moeglichst wenig Entscheidungen und moeglichst wenig Klicks erreichbar sein.
|
||||
- Sekundaere Funktionen duerfen nicht die Kernaufgabe stoeren.
|
||||
|
||||
### 2. Klare Orientierung
|
||||
|
||||
- Nutzer muessen jederzeit erkennen:
|
||||
- wo sie sich befinden
|
||||
- was hier moeglich ist
|
||||
- was der naechste sinnvolle Schritt ist
|
||||
|
||||
### 3. Konsistente Interaktion
|
||||
|
||||
- Gleiche Interaktionsmuster muessen sich in der gesamten App gleich verhalten.
|
||||
- Buttons, Dialoge, Tabs, Listen, Formulare und Menues duerfen nicht je Bereich eigene Bedienlogiken entwickeln.
|
||||
|
||||
### 4. Fehlertoleranz statt Bestrafung
|
||||
|
||||
- Fehlbedienungen muessen auffangbar sein.
|
||||
- Kritische Aktionen brauchen klare Rueckmeldung, wo sinnvoll Bestätigung und wenn moeglich Undo oder sichere Rueckwege.
|
||||
|
||||
### 5. Geschwindigkeit fuer geuebte Nutzer
|
||||
|
||||
- Power-User sollen die App schnell nutzen koennen.
|
||||
- Haeufige Wege duerfen nicht durch uebermaessige Zwischenschritte ausgebremst werden.
|
||||
|
||||
## Nicht-Ziele
|
||||
|
||||
- Keine komplette Informationsarchitektur-Neuerfindung in einem Schritt.
|
||||
- Kein sofortiger Umbau jedes Workflows gleichzeitig.
|
||||
- Keine reine Accessibility-Checklistenarbeit ohne echten Nutzwert.
|
||||
|
||||
## Ausgangsprobleme
|
||||
|
||||
Aus dem aktuellen Projektstand und den bisherigen Umbauten ergeben sich fuer die Bedienbarkeit vor allem diese Problemklassen:
|
||||
|
||||
- Uneinheitliche Bedienmuster zwischen alten und neueren Bereichen
|
||||
- zu viele tiefe Menues und bereichsspezifische Sonderlogiken
|
||||
- einige Seiten mit hoher Funktionsdichte, aber schwacher Priorisierung
|
||||
- Mischformen aus statischen Shell-Bereichen und historischen inneren Scrollkonzepten
|
||||
- lokale Alt-Dialoge und Formmuster mit uneinheitlicher Rueckmeldung
|
||||
- Desktop-zentrierte Denkweise in Teilen von Admin, Minigames und Falukant
|
||||
|
||||
## Zielbild
|
||||
|
||||
Am Ende soll die App folgendes Nutzungsgefuehl bieten:
|
||||
|
||||
- Shell, Navigation und Dialoge verhalten sich vorhersagbar
|
||||
- jede Hauptseite zeigt klar eine Primaeraufgabe und erkennbare Sekundaeraufgaben
|
||||
- Formulare fuehren sauber durch Eingabe, Validierung und Abschluss
|
||||
- Statusaenderungen und Systemreaktionen sind sichtbar und verstaendlich
|
||||
- Falukant, Community, Blog und Lernen bleiben fachlich unterschiedlich, aber bedienbar aus einem Guss
|
||||
|
||||
## Arbeitsbereiche
|
||||
|
||||
### Bereich A: Navigation und Orientierung
|
||||
|
||||
Ziel:
|
||||
|
||||
- Nutzer finden schneller zum Ziel und verstehen Menuehierarchien besser.
|
||||
|
||||
Arbeitspunkte:
|
||||
|
||||
- Hauptnavigation auf tatsaechliche Primaerbereiche pruefen
|
||||
- Untermenues auf Redundanzen und Leerpfade pruefen
|
||||
- aktive Position und Kontext pro Bereich klarer machen
|
||||
- in tiefen Bereichen Rueckwege, Breadcrumb-artige Hinweise oder Bereichstitel konsistent gestalten
|
||||
- Schnellzugriffe nur dort einsetzen, wo sie echte Beschleunigung bringen
|
||||
|
||||
Abnahmekriterien:
|
||||
|
||||
- jeder Hauptmenuepunkt hat einen klaren Zweck
|
||||
- Menuepunkte ohne Untermenue verhalten sich direkt
|
||||
- tiefe Ansichten haben einen klaren Rueckweg
|
||||
- Nutzer muessen nicht raten, in welchem Bereich sie sich befinden
|
||||
|
||||
### Bereich B: Seitenhierarchie und Aufgabenfluss
|
||||
|
||||
Ziel:
|
||||
|
||||
- jede Seite hat einen erkennbaren Einstieg, eine Hauptaufgabe und einen lesbaren Aufbau
|
||||
|
||||
Arbeitspunkte:
|
||||
|
||||
- pro View Primaeraktion definieren
|
||||
- Informationsdichte dort reduzieren, wo gleichrangige Bloecke konkurrieren
|
||||
- visuelle Prioritaet zwischen Lesen, Auswaehlen, Bearbeiten und Absenden schaerfen
|
||||
- Tabellen, Listen und Karten jeweils auf ihren eigentlichen Einsatzzweck pruefen
|
||||
|
||||
Abnahmekriterien:
|
||||
|
||||
- Nutzer erkennen in wenigen Sekunden den Zweck einer Seite
|
||||
- Hauptaktionen sind oberhalb oder in unmittelbarer Naehe des relevanten Inhalts
|
||||
- Nebenfunktionen dominieren die Seite nicht mehr
|
||||
|
||||
### Bereich C: Formulare und Eingaben
|
||||
|
||||
Ziel:
|
||||
|
||||
- Formulare sollen verstaendlich, fehlertolerant und effizient sein
|
||||
|
||||
Arbeitspunkte:
|
||||
|
||||
- Labels, Hilfetexte, Pflichtfelder und Fehlermeldungen vereinheitlichen
|
||||
- Validierung naeher an den Eingabepunkt bringen
|
||||
- unklare Zustandswechsel vermeiden
|
||||
- Speichern, Abbrechen und gefaehrliche Aktionen konsistent platzieren
|
||||
- Checkboxen, Radios und Selects auf Lesbarkeit und Trefferflaechen pruefen
|
||||
|
||||
Abnahmekriterien:
|
||||
|
||||
- jeder Eingabefehler ist erkennbar und nachvollziehbar
|
||||
- Speichern fuehlt sich in allen Bereichen gleich an
|
||||
- keine Form wirkt wie ein historischer Sonderfall
|
||||
|
||||
### Bereich D: Dialoge, Feedback und Systemstatus
|
||||
|
||||
Ziel:
|
||||
|
||||
- Systemreaktionen muessen sichtbar, verstaendlich und nicht stoerend sein
|
||||
|
||||
Arbeitspunkte:
|
||||
|
||||
- Dialogarten unterscheiden:
|
||||
- bestaetigend
|
||||
- informierend
|
||||
- bearbeitend
|
||||
- kritisch
|
||||
- Messagebox/Dialog-Rueckmeldungen vereinheitlichen
|
||||
- Ladezustaende, Erfolg, Fehler und Leere Zustaende standardisieren
|
||||
- offene Dialoge und Fensterleiste auf Priorisierung pruefen
|
||||
|
||||
Abnahmekriterien:
|
||||
|
||||
- Nutzer verstehen, was gerade passiert ist oder noch passiert
|
||||
- kritische Aktionen sind klar markiert
|
||||
- Modale Interaktionen blockieren nur, wenn es wirklich noetig ist
|
||||
|
||||
### Bereich E: Mobile und kleine Viewports
|
||||
|
||||
Ziel:
|
||||
|
||||
- zentrale Aufgaben muessen auch auf kleineren Screens robust funktionieren
|
||||
|
||||
Arbeitspunkte:
|
||||
|
||||
- Shell mit Header, Navigation, Content und Footer auf reale Nutzungsszenarien pruefen
|
||||
- Tabellen und breite Steuermasken auf mobile Alternativen oder horizontale Strategien prüfen
|
||||
- Touch-Ziele und Abstaende angleichen
|
||||
- Hover-abhaengige Muster fuer Touch absichern
|
||||
|
||||
Abnahmekriterien:
|
||||
|
||||
- Kernaufgaben sind auf Tablet und Smartphone ohne Layoutbruch nutzbar
|
||||
- keine kritische Funktion ist nur via Hover oder Pixel-Präzision erreichbar
|
||||
|
||||
### Bereich F: Komplexe Produktbereiche
|
||||
|
||||
Ziel:
|
||||
|
||||
- Falukant, Vokabeltrainer, Minigames und Admin sollen fachlich komplex bleiben, aber leichter steuerbar werden
|
||||
|
||||
Arbeitspunkte:
|
||||
|
||||
- Falukant:
|
||||
- Schnellzugriffsleiste, Tab-Struktur, Statusfeedback und Arbeitsablaeufe priorisieren
|
||||
- Vokabeltrainer:
|
||||
- Lernpfad, Bearbeitung, Suche, Uebung und Fortschritt klarer trennen
|
||||
- Minigames:
|
||||
- Einstieg, Pause, Statusanzeige und Kampagnenfluss vereinfachen
|
||||
- Admin:
|
||||
- Such-, Editier- und Bestaetigungsfluesse entlasten
|
||||
|
||||
Abnahmekriterien:
|
||||
|
||||
- komplexe Bereiche sind ohne Einlernen nicht sofort trivial, aber deutlich besser fuehrend
|
||||
- wiederkehrende Aktionen sind schneller und sicherer bedienbar
|
||||
|
||||
## Methodik
|
||||
|
||||
### 1. Bedienbarkeits-Audit
|
||||
|
||||
Pro Hauptbereich wird ein kurzer Audit gemacht:
|
||||
|
||||
- Primaeraufgabe der Seite
|
||||
- haeufigste Nutzeraktion
|
||||
- groesste Reibung
|
||||
- groesstes Fehlerrisiko
|
||||
- mobile Schwachstelle
|
||||
|
||||
Empfohlene Cluster:
|
||||
|
||||
- Shell und Navigation
|
||||
- Home und Einstieg
|
||||
- Community/Social
|
||||
- Blog
|
||||
- Vokabeltrainer
|
||||
- Falukant
|
||||
- Admin
|
||||
- Minigames
|
||||
- Dialoge/Formulare
|
||||
|
||||
### 2. Aufgabenorientierte Review-Szenarien
|
||||
|
||||
Die App wird nicht nur nach Komponenten, sondern nach Aufgaben geprueft:
|
||||
|
||||
- registrieren und einloggen
|
||||
- Profil/Freunde finden
|
||||
- Forumsthema finden und beantworten
|
||||
- Vokabelsprache erstellen, abonnieren, lernen
|
||||
- Falukant-Status pruefen und Folgeaktion ausfuehren
|
||||
- Admin-Nutzer suchen und aendern
|
||||
- Match3 starten, pausieren, neu starten
|
||||
|
||||
### 3. Fix-Kategorien
|
||||
|
||||
Alle Probleme werden in vier Kategorien eingeordnet:
|
||||
|
||||
- P1: blockiert oder verwirrt Kernnutzung deutlich
|
||||
- P2: verlangsamt Nutzung oder erzeugt Fehlbedienung
|
||||
- P3: stoert Konsistenz oder Lesbarkeit
|
||||
- P4: Feinschliff ohne unmittelbaren Schaden
|
||||
|
||||
## Umsetzungsphasen
|
||||
|
||||
### Phase U1: Audit und Problemkatalog
|
||||
|
||||
Ergebnis:
|
||||
|
||||
- kompakte Liste realer Bedienprobleme pro Hauptbereich
|
||||
- priorisiert nach P1 bis P4
|
||||
|
||||
Arbeit:
|
||||
|
||||
- 1 Durchgang Desktop
|
||||
- 1 Durchgang kleiner Viewport
|
||||
- 1 Durchgang fuer Tastatur-/Dialognutzung
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- Audit dokumentiert in `docs/USABILITY_AUDIT_U1.md`
|
||||
- priorisierte Folgephase: `U2 Shell, Navigation und Feedback`
|
||||
|
||||
### Phase U2: Shell, Navigation, Feedback
|
||||
|
||||
Ergebnis:
|
||||
|
||||
- globale Bedienmuster sind konsistent
|
||||
|
||||
Arbeit:
|
||||
|
||||
- Navigation
|
||||
- Rueckwege
|
||||
- Fokuslogik
|
||||
- Dialog-/Feedbacksystem
|
||||
- Lade- und Leerezustaende
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- Shell-Kontextbereich mit Bereichstitel und Rueckweg umgesetzt
|
||||
- Navigation um klareren Seitenkontext ergaenzt
|
||||
- zentrales Feedback-API eingefuehrt
|
||||
- Standard-Feedbackdialoge visuell und technisch vereinheitlicht
|
||||
- Kernfluesse aus Auth und Settings auf das neue Feedbackmuster umgestellt
|
||||
|
||||
### Phase U3: Formulare und Abschlussfluesse
|
||||
|
||||
Ergebnis:
|
||||
|
||||
- Eingaben, Speichern, Validierung und Rueckmeldung sind vereinheitlicht
|
||||
|
||||
Arbeit:
|
||||
|
||||
- Auth
|
||||
- Settings
|
||||
- Admin
|
||||
- Falukant-Formulare
|
||||
- Vokabel-Bearbeitungsfluesse
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- gemeinsames Formularmuster fuer Hinweise, Fehler und Action-Row eingefuehrt
|
||||
- Dialogbuttons respektieren Disabled-Zustaende
|
||||
- Auth-Dialoge, Account-Settings, zentrale Admin-/Falukant-Formulare und Vokabel-Bearbeitungsfluesse auf sichtbarere Validierung und konsistentere Abschlusslogik umgestellt
|
||||
|
||||
### Phase U4: Komplexe Fachbereiche
|
||||
|
||||
Ergebnis:
|
||||
|
||||
- Falukant, Vokabeltrainer, Minigames und Admin sind auf Nutzbarkeit statt nur Funktion geprüft
|
||||
|
||||
Arbeit:
|
||||
|
||||
- Arbeitsablaeufe entlasten
|
||||
- Primaeraktionen schaerfen
|
||||
- Informationslast reduzieren
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- Vokabeltrainer als Aufgabenhub mit getrennten Bereichen fuer eigene und abonnierte Sprachen
|
||||
- Falukant-Uebersicht um Routinekarten, verdichtete Kennzahlen und schnellere Folgeaktionen erweitert
|
||||
- Match3-Spiel um Ziel-/Statusleiste fuer den naechsten sinnvollen Schritt ergaenzt
|
||||
- Match3-Admin um klaren 3-Schritt-Arbeitsfluss, Formzusammenfassung und sicherere Speicherlogik erweitert
|
||||
|
||||
### Phase U5: Mobile und Endabnahme
|
||||
|
||||
Ergebnis:
|
||||
|
||||
- Kernaufgaben sind auf Standard-Viewports belastbar
|
||||
|
||||
Arbeit:
|
||||
|
||||
- letzte Layoutkorrekturen
|
||||
- Touch und Fokus
|
||||
- Abschlussreview entlang echter Nutzerszenarien
|
||||
|
||||
Aktueller Stand:
|
||||
|
||||
- abgeschlossen
|
||||
- Hauptnavigation auf kleinen Viewports zu einer verlässlich aufklappbaren Mobilnavigation mit Touch-gerechten Zielgroessen umgebaut
|
||||
- Header und Footer auf kleine Breiten mit stabileren Status- und Linkblöcken angepasst
|
||||
- Dialoge fuer kleine Viewports auf sichere Maximalgroessen und mobile Button-Stacks begrenzt
|
||||
- breite Tabellen auf kleinen Screens per horizontalem Scroll-Fallback abgesichert
|
||||
- globale Touch-Ziele fuer Buttons leicht vergroessert und letzte Shell-Kanten geglaettet
|
||||
|
||||
## Konkreter Arbeitskatalog
|
||||
|
||||
### 1. Shell und Navigation
|
||||
|
||||
- Menuepunkte auf Nutzungsprioritaet sortieren
|
||||
- Untermenues auf direkte Zielerreichung pruefen
|
||||
- Bereichskontext pro Seite konsistent machen
|
||||
- globale Ruecksprunglogik definieren
|
||||
|
||||
### 2. Dialog- und Feedbacksystem
|
||||
|
||||
- Dialogtypen definieren und dokumentieren
|
||||
- Standard fuer Erfolg, Fehler, Warnung, Leere, Laden festlegen
|
||||
- Inline-Feedback vor modalem Feedback bevorzugen, wenn kein harter Block noetig ist
|
||||
|
||||
### 3. Formsystem
|
||||
|
||||
- ein gemeinsames Muster fuer Label, Hilfetext, Fehlermeldung, Pflichtfeld
|
||||
- ein gemeinsames Muster fuer Save/Cancel/Delete
|
||||
- ein gemeinsames Muster fuer Tabellenfilter und Suchformulare
|
||||
|
||||
### 4. Bereichsreviews
|
||||
|
||||
- Social/Friends/Search/Forum entlang echter Aufgaben pruefen
|
||||
- Vokabeltrainer entlang des Lernpfads pruefen
|
||||
- Falukant entlang taeglicher Routinen pruefen
|
||||
- Admin entlang Such-/Editier-Routinen pruefen
|
||||
- Minigames entlang Einstieg/Pause/Neustart pruefen
|
||||
|
||||
### 5. Mobile Review
|
||||
|
||||
- Header/Nav/Footer mit realen Hoehen pruefen
|
||||
- breite Inhalte auf kleine Screens pruefen
|
||||
- Dialoge und Tabellen fuer Touch pruefen
|
||||
|
||||
## Definition of Done
|
||||
|
||||
Das Bedienbarkeitsprojekt gilt als abgeschlossen, wenn:
|
||||
|
||||
- fuer alle Hauptbereiche ein Audit stattgefunden hat
|
||||
- P1- und P2-Probleme aus dem Audit abgearbeitet sind
|
||||
- Navigation, Formulare, Dialoge und Feedback nach gemeinsamen Regeln funktionieren
|
||||
- Kernaufgaben auf Desktop und kleinem Viewport ohne strukturelle Reibung moeglich sind
|
||||
- Restpunkte nur noch P3/P4-Feinschliff sind
|
||||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
1. Audit ueber Kernaufgaben
|
||||
2. Shell/Navigation/Feedback
|
||||
3. Formulare und Abschlusslogik
|
||||
4. Falukant, Vokabeltrainer, Admin, Minigames
|
||||
5. Mobile Endabnahme
|
||||
|
||||
## Naechster konkreter Schritt
|
||||
|
||||
Der erste sinnvolle Umsetzungsschritt ist nicht sofort Code, sondern ein kurzer UX-Audit-Durchgang ueber die wichtigsten Aufgabenfluesse. Daraus entsteht ein priorisierter Problemkatalog, auf dessen Basis die Bedienbarkeitsarbeit strukturiert umgesetzt wird.
|
||||
@@ -1,4 +1,5 @@
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
VITE_API_BASE_URL=http://127.0.0.1:2020
|
||||
VITE_PUBLIC_BASE_URL=http://localhost:5173
|
||||
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
|
||||
VITE_DAEMON_SOCKET=ws://localhost:4551
|
||||
VITE_CHAT_WS_URL=ws://localhost.de:1235
|
||||
VITE_DAEMON_SOCKET=ws://127.0.0.1:4551
|
||||
VITE_CHAT_WS_URL=ws://127.0.0.1:1235
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
VITE_API_BASE_URL=https://www.your-part.de
|
||||
VITE_PUBLIC_BASE_URL=https://www.your-part.de
|
||||
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
|
||||
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
|
||||
VITE_CHAT_WS_URL=wss://www.your-part.de:1235
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
VITE_API_BASE_URL=https://www.your-part.de
|
||||
VITE_PUBLIC_BASE_URL=https://www.your-part.de
|
||||
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
|
||||
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
|
||||
VITE_CHAT_WS_URL=wss://www.your-part.de:1235
|
||||
VITE_SOCKET_IO_URL=https://www.your-part.de:4443
|
||||
|
||||
|
||||
@@ -8,70 +8,37 @@
|
||||
<meta name="description" content="YourPart vereint Community, Chat, Forum, soziales Netzwerk mit Bildergalerie, Vokabeltrainer, das Aufbauspiel Falukant sowie Minispiele wie Match3 und Taxi. Die Plattform befindet sich in der Beta‑Phase und wird laufend erweitert." />
|
||||
<meta name="keywords" content="YourPart, Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer, Sprachen lernen, Falukant, Aufbauspiel, Minispiele, Match3, Taxi, Bildergalerie, Tagebuch, Freundschaften" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://www.your-part.de/" />
|
||||
<link rel="canonical" href="%VITE_PUBLIC_BASE_URL%/" />
|
||||
<meta name="author" content="YourPart" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="YourPart" />
|
||||
<meta property="og:title" content="YourPart – Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
|
||||
<meta property="og:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele – jetzt in der Beta testen." />
|
||||
<meta property="og:url" content="https://www.your-part.de/" />
|
||||
<meta property="og:url" content="%VITE_PUBLIC_BASE_URL%/" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
<meta property="og:image" content="%VITE_PUBLIC_BASE_URL%/images/logos/logo.png" />
|
||||
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="YourPart – Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
|
||||
<meta name="twitter:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele – jetzt in der Beta testen." />
|
||||
<meta name="twitter:image" content="%VITE_PUBLIC_BASE_URL%/images/logos/logo.png" />
|
||||
|
||||
<meta name="theme-color" content="#FF8C5A" />
|
||||
<link rel="alternate" hreflang="de" href="%VITE_PUBLIC_BASE_URL%/" />
|
||||
<link rel="alternate" hreflang="x-default" href="%VITE_PUBLIC_BASE_URL%/" />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "YourPart",
|
||||
"url": "https://www.your-part.de/",
|
||||
"inLanguage": "de",
|
||||
"description": "Community-Plattform mit Chat, Forum, Vokabeltrainer, Aufbauspiel Falukant und Minispielen",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://www.your-part.de/?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
},
|
||||
"about": [
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Vokabeltrainer",
|
||||
"description": "Lerne Sprachen mit dem interaktiven Vokabeltrainer",
|
||||
"applicationCategory": "EducationalApplication",
|
||||
"url": "https://www.your-part.de/socialnetwork/vocab"
|
||||
},
|
||||
{
|
||||
"@type": "VideoGame",
|
||||
"name": "Falukant",
|
||||
"description": "Mittelalterliches Aufbauspiel mit Handel, Politik und Charakterentwicklung",
|
||||
"gamePlatform": "Web",
|
||||
"url": "https://www.your-part.de/falukant"
|
||||
},
|
||||
{
|
||||
"@type": "VideoGame",
|
||||
"name": "Match3",
|
||||
"description": "Klassisches Match-3 Puzzle-Spiel",
|
||||
"gamePlatform": "Web",
|
||||
"url": "https://www.your-part.de/minigames/match3"
|
||||
},
|
||||
{
|
||||
"@type": "VideoGame",
|
||||
"name": "Taxi",
|
||||
"description": "Taxi-Fahrspiel mit Passagieren und Strecken",
|
||||
"gamePlatform": "Web",
|
||||
"url": "https://www.your-part.de/minigames/taxi"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript>
|
||||
<section style="max-width:960px;margin:40px auto;padding:0 20px;font-family:Arial,sans-serif;line-height:1.6;">
|
||||
<h1>YourPart</h1>
|
||||
<p>YourPart ist eine Plattform fuer Community, Chat, Forum, Blogs, Vokabeltrainer, das Browser-Aufbauspiel Falukant und Minispiele.</p>
|
||||
<p>Wichtige Bereiche: <a href="/blogs">Blogs</a>, <a href="/vokabeltrainer">Vokabeltrainer</a>, <a href="/falukant">Falukant</a> und <a href="/minigames">Minispiele</a>.</p>
|
||||
</section>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /activate
|
||||
Disallow: /admin/
|
||||
Disallow: /friends
|
||||
Disallow: /personal/
|
||||
Disallow: /settings/
|
||||
Disallow: /socialnetwork/diary
|
||||
Disallow: /socialnetwork/forum/
|
||||
Disallow: /socialnetwork/forumtopic/
|
||||
Disallow: /socialnetwork/gallery
|
||||
Disallow: /socialnetwork/guestbook
|
||||
Disallow: /socialnetwork/search
|
||||
Disallow: /socialnetwork/vocab/
|
||||
Disallow: /falukant/home
|
||||
Disallow: /falukant/create
|
||||
Disallow: /falukant/branch/
|
||||
Disallow: /falukant/moneyhistory
|
||||
Disallow: /falukant/family
|
||||
Disallow: /falukant/house
|
||||
Disallow: /falukant/nobility
|
||||
Disallow: /falukant/reputation
|
||||
Disallow: /falukant/church
|
||||
Disallow: /falukant/education
|
||||
Disallow: /falukant/bank
|
||||
Disallow: /falukant/directors
|
||||
Disallow: /falukant/health
|
||||
Disallow: /falukant/politics
|
||||
Disallow: /falukant/darknet
|
||||
Disallow: /minigames/match3
|
||||
Disallow: /minigames/taxi
|
||||
|
||||
Sitemap: https://www.your-part.de/sitemap.xml
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
<url>
|
||||
<loc>https://www.your-part.de/blogs</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.your-part.de/vokabeltrainer</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
@@ -16,25 +21,8 @@
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.your-part.de/socialnetwork/vocab</loc>
|
||||
<loc>https://www.your-part.de/minigames</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.your-part.de/minigames</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.your-part.de/minigames/match3</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.your-part.de/minigames/taxi</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<AppHeader />
|
||||
<AppNavigation v-if="isLoggedIn && user.active" />
|
||||
<AppContent />
|
||||
<div id="app" class="app-shell">
|
||||
<AppHeader class="app-shell__header" />
|
||||
<AppNavigation v-if="isLoggedIn && user.active" class="app-shell__nav" />
|
||||
<AppContent class="app-shell__content" />
|
||||
<AppFooter />
|
||||
<AnswerContact ref="answerContactDialog" />
|
||||
<RandomChatDialog ref="randomChatDialog" />
|
||||
@@ -42,9 +42,6 @@ import MultiChatDialog from './dialogues/chat/MultiChatDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
mounted() {
|
||||
document.title = 'yourPart';
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isLoggedIn', 'user'])
|
||||
},
|
||||
@@ -74,10 +71,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,3 +4,17 @@ export const fetchPublicRooms = async () => {
|
||||
const response = await apiClient.get("/api/chat/rooms");
|
||||
return response.data; // expecting array of { id, title, ... }
|
||||
};
|
||||
|
||||
export const fetchRoomCreateOptions = async () => {
|
||||
const response = await apiClient.get("/api/chat/room-create-options");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchOwnRooms = async () => {
|
||||
const response = await apiClient.get("/api/chat/my-rooms");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteOwnRoom = async (roomId) => {
|
||||
await apiClient.delete(`/api/chat/my-rooms/${roomId}`);
|
||||
};
|
||||
|
||||
@@ -1,50 +1,355 @@
|
||||
:root {
|
||||
/* Moderne Farbpalette für bessere Lesbarkeit */
|
||||
--color-primary-orange: #FFB84D; /* Gelbliches, sanftes Orange */
|
||||
--color-primary-orange-hover: #FFC966; /* Noch helleres gelbliches Orange für Hover */
|
||||
--color-primary-orange-light: #FFF8E1; /* Sehr helles gelbliches Orange für Hover-Hintergründe */
|
||||
--color-primary-green: #4ADE80; /* Helles, freundliches Grün - passt zum Orange */
|
||||
--color-primary-green-hover: #6EE7B7; /* Noch helleres Grün für Hover */
|
||||
--color-text-primary: #1F2937; /* Dunkles Grau für Haupttext (bessere Lesbarkeit) */
|
||||
--color-text-secondary: #5D4037; /* Dunkles Braun für sekundären Text/Hover */
|
||||
--color-text-on-orange: #000000; /* Schwarz auf Orange */
|
||||
--color-text-on-green: #000000; /* Schwarz auf Grün */
|
||||
color-scheme: light;
|
||||
|
||||
--font-display: "Trebuchet MS", "Segoe UI", sans-serif;
|
||||
--font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
--color-bg: #f4f1ea;
|
||||
--color-bg-elevated: #faf7f1;
|
||||
--color-bg-muted: #f5eee2;
|
||||
--color-surface: rgba(255, 251, 246, 0.94);
|
||||
--color-surface-strong: #fffdfa;
|
||||
--color-surface-accent: #fff4e5;
|
||||
--color-border: rgba(93, 64, 55, 0.12);
|
||||
--color-border-strong: rgba(93, 64, 55, 0.24);
|
||||
|
||||
--color-text-primary: #211910;
|
||||
--color-text-secondary: #5f4b39;
|
||||
--color-text-muted: #7a6857;
|
||||
--color-text-on-accent: #fffaf4;
|
||||
|
||||
--color-primary: #f8a22b;
|
||||
--color-primary-hover: #ea961f;
|
||||
--color-primary-soft: rgba(248, 162, 43, 0.14);
|
||||
--color-secondary: #78c38a;
|
||||
--color-secondary-soft: rgba(120, 195, 138, 0.18);
|
||||
--color-highlight: #ffcf74;
|
||||
|
||||
--color-success: #287d5a;
|
||||
--color-warning: #c9821f;
|
||||
--color-danger: #b13b35;
|
||||
|
||||
--shell-max-width: 1440px;
|
||||
--content-max-width: 1200px;
|
||||
--header-height: 62px;
|
||||
--nav-height: 52px;
|
||||
--footer-height: 46px;
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-12: 48px;
|
||||
|
||||
--radius-sm: 5px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-soft: 0 12px 30px rgba(47, 29, 14, 0.08);
|
||||
--shadow-medium: 0 20px 50px rgba(47, 29, 14, 0.12);
|
||||
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
|
||||
--transition-fast: 140ms ease;
|
||||
--transition-base: 220ms ease;
|
||||
|
||||
--color-primary-orange: var(--color-primary);
|
||||
--color-primary-orange-hover: var(--color-primary-hover);
|
||||
--color-primary-orange-light: #f9ece1;
|
||||
--color-primary-green: #84c6a3;
|
||||
--color-primary-green-hover: #95d1b0;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.85), transparent 30%),
|
||||
linear-gradient(180deg, #f8f2e8 0%, #f3ebdd 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f4f4f4;
|
||||
font-family: var(--font-body);
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 10px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
background: var(--color-primary-orange);
|
||||
color: var(--color-text-on-orange);
|
||||
border: 1px solid var(--color-primary-orange);
|
||||
border-radius: 4px;
|
||||
transition: background 0.05s;
|
||||
a:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.button,
|
||||
span.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary);
|
||||
color: #2b1f14;
|
||||
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
button:hover,
|
||||
.button:hover,
|
||||
span.button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: var(--color-primary-hover);
|
||||
box-shadow: 0 10px 18px rgba(248, 162, 43, 0.24);
|
||||
}
|
||||
|
||||
button:active,
|
||||
.button:active,
|
||||
span.button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
.button:disabled,
|
||||
span.button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
a:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[role="menuitem"]:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid rgba(120, 195, 138, 0.32);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]),
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--color-text-primary);
|
||||
box-shadow: var(--shadow-inset);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
padding: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]):hover,
|
||||
select:hover,
|
||||
textarea:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):not([type="radio"]):focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: rgba(120, 195, 138, 0.65);
|
||||
box-shadow: 0 0 0 4px rgba(120, 195, 138, 0.16);
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
inline-size: 16px;
|
||||
block-size: 16px;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
inline-size: 16px;
|
||||
block-size: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-family: var(--font-display);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2rem, 3.4vw, 3.6rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 2vw, 2.4rem);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: clamp(1.15rem, 1.5vw, 1.5rem);
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol {
|
||||
margin: 0 0 var(--space-4);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
main,
|
||||
.contenthidden {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contentscroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-content__inner > .contenthidden {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-content__inner > .contenthidden > .contentscroll {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.surface-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-field > label,
|
||||
.form-field > span:first-child {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.field-error {
|
||||
border-color: rgba(177, 59, 53, 0.44) !important;
|
||||
box-shadow: 0 0 0 4px rgba(177, 59, 53, 0.12) !important;
|
||||
}
|
||||
|
||||
.form-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.rc-system {
|
||||
@@ -52,25 +357,13 @@ button:hover {
|
||||
}
|
||||
|
||||
.rc-self {
|
||||
color: #ff0000;
|
||||
font-weight: bold;
|
||||
color: #c0412c;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rc-partner {
|
||||
color: #0000ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-primary-orange);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
display: block;
|
||||
color: #2357b5;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.multiselect__option--highlight,
|
||||
@@ -80,61 +373,65 @@ h3 {
|
||||
.multiselect__option--highlight[data-selected],
|
||||
.multiselect__option--highlight[data-deselect] {
|
||||
background: none;
|
||||
background-color: var(--color-primary-orange);
|
||||
color: var(--color-text-on-orange);
|
||||
}
|
||||
|
||||
span.button {
|
||||
padding: 2px 2px;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--color-primary-orange);
|
||||
color: var(--color-text-on-orange);
|
||||
border: 1px solid var(--color-primary-orange);
|
||||
border-radius: 4px;
|
||||
transition: background 0.05s;
|
||||
border: 1px solid transparent;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
span.button:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-text-on-accent);
|
||||
}
|
||||
|
||||
.font-color-gender-male {
|
||||
color: #1E90FF;
|
||||
color: #1e90ff;
|
||||
}
|
||||
|
||||
.font-color-gender-female {
|
||||
color: #FF69B4;
|
||||
color: #d14682;
|
||||
}
|
||||
|
||||
.font-color-gender-transmale {
|
||||
color: #00CED1;
|
||||
color: #1f8b9b;
|
||||
}
|
||||
|
||||
.font-color-gender-transfemale {
|
||||
color: #FFB6C1;
|
||||
color: #d78398;
|
||||
}
|
||||
|
||||
.font-color-gender-nonbinary {
|
||||
color: #DAA520;
|
||||
color: #ba7c1f;
|
||||
}
|
||||
|
||||
main,
|
||||
.contenthidden {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@media (max-width: 960px) {
|
||||
:root {
|
||||
--header-height: 56px;
|
||||
--nav-height: auto;
|
||||
--footer-height: auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(1.8rem, 8vw, 2.8rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.35rem, 5vw, 2rem);
|
||||
}
|
||||
|
||||
.contentscroll table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html:focus-within {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.contentscroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -1,26 +1,51 @@
|
||||
<template>
|
||||
<main class="contenthidden">
|
||||
<div class="contentscroll">
|
||||
<main class="app-content contenthidden">
|
||||
<div class="app-content__scroll contentscroll">
|
||||
<div class="app-content__inner">
|
||||
<AppSectionBar />
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppSectionBar from './AppSectionBar.vue';
|
||||
|
||||
export default {
|
||||
name: 'AppContent'
|
||||
name: 'AppContent',
|
||||
components: {
|
||||
AppSectionBar
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
padding: 0;
|
||||
background-color: #ffffff;
|
||||
.app-content {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.contentscroll {
|
||||
padding: 20px;
|
||||
.app-content__scroll {
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-content__inner {
|
||||
max-width: var(--shell-max-width);
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 14px 18px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-content__inner {
|
||||
padding: 12px 12px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
<template>
|
||||
<footer>
|
||||
<div class="logo" @click="showFalukantDaemonStatus"><img src="/images/icons/logo_color.png"></div>
|
||||
<div class="window-bar">
|
||||
<button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button"
|
||||
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle">
|
||||
<footer class="app-footer">
|
||||
<div class="app-footer__inner">
|
||||
<div class="footer-system">
|
||||
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
|
||||
<img src="/images/icons/logo_color.png" alt="YourPart" />
|
||||
<span>System</span>
|
||||
</button>
|
||||
<span class="footer-caption">
|
||||
{{ openDialogs.length === 0 ? 'Keine offenen Dialoge' : `${openDialogs.length} Fenster aktiv` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="window-bar" :class="{ 'window-bar--empty': openDialogs.length === 0 }">
|
||||
<button
|
||||
v-for="dialog in openDialogs"
|
||||
:key="dialog.dialog.name"
|
||||
class="dialog-button"
|
||||
@click="toggleDialogMinimize(dialog.dialog.name)"
|
||||
:title="dialog.dialog.localTitle"
|
||||
>
|
||||
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
|
||||
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
|
||||
dialog.dialog.localTitle }}</span>
|
||||
</button>
|
||||
<span v-if="openDialogs.length === 0" class="window-bar__empty">System bereit</span>
|
||||
</div>
|
||||
|
||||
<div class="static-block">
|
||||
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
|
||||
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
|
||||
<a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -63,18 +81,59 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
footer {
|
||||
display: flex;
|
||||
background-color: var(--color-primary-green);
|
||||
height: 38px;
|
||||
width: 100%;
|
||||
color: #1F2937; /* Dunkles Grau für besseren Kontrast auf hellem Grün */
|
||||
.app-footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logo,
|
||||
.window-bar,
|
||||
.static-block {
|
||||
text-align: center;
|
||||
.app-footer__inner {
|
||||
max-width: var(--shell-max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 44px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(242, 248, 243, 0.96) 0%, rgba(224, 238, 227, 0.98) 100%);
|
||||
border-top: 1px solid rgba(120, 195, 138, 0.28);
|
||||
box-shadow: 0 -6px 18px rgba(93, 64, 55, 0.06);
|
||||
}
|
||||
|
||||
.footer-system {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
min-height: 32px;
|
||||
padding: 0 10px 0 8px;
|
||||
background: rgba(120, 195, 138, 0.12);
|
||||
border: 1px solid rgba(120, 195, 138, 0.22);
|
||||
color: #24523a;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.footer-brand:hover {
|
||||
background: rgba(120, 195, 138, 0.18);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.footer-brand img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.footer-brand span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.footer-caption {
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.window-bar {
|
||||
@@ -83,24 +142,39 @@ footer {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding-left: 10px;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
border: 1px solid rgba(120, 195, 138, 0.16);
|
||||
}
|
||||
|
||||
.window-bar--empty {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.window-bar__empty {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
max-width: 12em;
|
||||
max-width: 15em;
|
||||
min-height: 30px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
height: 1.8em;
|
||||
border: 1px solid #0a4337;
|
||||
box-shadow: 1px 1px 2px #484949;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid rgba(120, 195, 138, 0.18);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dialog-button:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.dialog-button>img {
|
||||
@@ -111,16 +185,71 @@ footer {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.logo>img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.static-block {
|
||||
line-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid rgba(120, 195, 138, 0.22);
|
||||
}
|
||||
|
||||
.static-block>a {
|
||||
padding-right: 1.5em;
|
||||
color: #42634e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.static-block > a:hover {
|
||||
color: #24523a;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-footer__inner {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-system,
|
||||
.window-bar,
|
||||
.static-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-system {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.static-block {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
border-top: 1px solid rgba(120, 195, 138, 0.2);
|
||||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-footer__inner {
|
||||
gap: 10px;
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.footer-system {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.window-bar {
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.static-block {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,27 @@
|
||||
<template>
|
||||
<header>
|
||||
<div class="logo"><img src="/images/logos/logo.png" /></div>
|
||||
<div class="advertisement">Advertisement</div>
|
||||
<header class="app-header">
|
||||
<div class="app-header__inner">
|
||||
<div class="brand">
|
||||
<div class="logo"><img src="/images/logos/logo.png" alt="YourPart" /></div>
|
||||
<div class="brand-copy">
|
||||
<strong>YourPart</strong>
|
||||
<span>Community-Plattform</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<div class="header-meta__context">
|
||||
<span class="header-pill">Beta</span>
|
||||
</div>
|
||||
<div class="connection-status" v-if="isLoggedIn">
|
||||
<div class="status-indicator" :class="backendStatusClass">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">B</span>
|
||||
<span class="status-text">Backend</span>
|
||||
</div>
|
||||
<div class="status-indicator" :class="daemonStatusClass">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">D</span>
|
||||
<span class="status-text">Daemon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -43,43 +55,118 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
.app-header {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 249, 240, 0.96) 0%, rgba(246, 236, 220, 0.98) 100%);
|
||||
color: #2b1f14;
|
||||
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
|
||||
box-shadow: 0 5px 14px rgba(93, 64, 55, 0.06);
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
max-width: var(--shell-max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
background-color: #f8a22b;
|
||||
gap: 16px;
|
||||
}
|
||||
.logo, .title, .advertisement {
|
||||
text-align: center;
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.advertisement {
|
||||
flex: 1;
|
||||
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 162, 43, 0.18) 0%, rgba(255, 255, 255, 0.76) 100%);
|
||||
border: 1px solid rgba(248, 162, 43, 0.22);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.logo > img {
|
||||
max-height: 50px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
font-size: 1rem;
|
||||
line-height: 1.1;
|
||||
color: #3a2a1b;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
font-size: 0.74rem;
|
||||
color: rgba(95, 75, 57, 0.78);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-meta__context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-pill {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
border: 1px solid rgba(248, 162, 43, 0.24);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #8a5411;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
gap: 5px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 6pt;
|
||||
font-weight: 500;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid rgba(93, 64, 55, 0.1);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@@ -100,23 +187,23 @@ header {
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: #2e7d32;
|
||||
background-color: rgba(76, 175, 80, 0.12);
|
||||
color: #245b2c;
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
color: #f57c00;
|
||||
background-color: rgba(255, 152, 0, 0.12);
|
||||
color: #8b5e0d;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
color: #d32f2f;
|
||||
background-color: rgba(244, 67, 54, 0.12);
|
||||
color: #8f2c27;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
color: #d32f2f;
|
||||
background-color: rgba(244, 67, 54, 0.12);
|
||||
color: #8f2c27;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
@@ -124,4 +211,53 @@ header {
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-header {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.app-header__inner {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-meta__context {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
font-size: 0.76rem;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-header__inner {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.brand {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
min-height: 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
<template>
|
||||
<nav>
|
||||
<nav
|
||||
ref="navRoot"
|
||||
class="app-navigation"
|
||||
:class="{ 'app-navigation--suppress-hover': suppressHover }"
|
||||
>
|
||||
<div class="nav-primary">
|
||||
<ul>
|
||||
<!-- Hauptmenü -->
|
||||
<li
|
||||
v-for="(item, key) in menu"
|
||||
:key="key"
|
||||
class="mainmenuitem"
|
||||
@click="handleItem(item, $event)"
|
||||
:class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key) }"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-haspopup="hasTopLevelSubmenu(item) ? 'menu' : undefined"
|
||||
:aria-expanded="hasTopLevelSubmenu(item) ? String(isMainExpanded(key)) : undefined"
|
||||
@click="handleItem(item, $event, key)"
|
||||
@keydown.enter.prevent="handleItem(item, $event, key)"
|
||||
@keydown.space.prevent="handleItem(item, $event, key)"
|
||||
>
|
||||
<span
|
||||
v-if="item.icon"
|
||||
:style="`background-image:url('/images/icons/${item.icon}')`"
|
||||
class="menu-icon"
|
||||
> </span>
|
||||
<span>{{ $t(`navigation.${key}`) }}</span>
|
||||
<span class="mainmenuitem__label">{{ $t(`navigation.${key}`) }}</span>
|
||||
<span v-if="hasTopLevelSubmenu(item)" class="mainmenuitem__caret">▾</span>
|
||||
|
||||
<!-- Untermenü Ebene 1 -->
|
||||
<ul v-if="item.children" class="submenu1">
|
||||
<!-- Untermenü Ebene 1 -->
|
||||
<ul v-if="hasTopLevelSubmenu(item)" class="submenu1" :class="{ 'submenu1--open': isMainExpanded(key) }">
|
||||
<li
|
||||
v-for="(subitem, subkey) in item.children"
|
||||
:key="subkey"
|
||||
@click="handleItem(subitem, $event)"
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
:class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`) }"
|
||||
@click="handleSubItem(subitem, subkey, key, $event)"
|
||||
@keydown.enter.prevent="handleSubItem(subitem, subkey, key, $event)"
|
||||
@keydown.space.prevent="handleSubItem(subitem, subkey, key, $event)"
|
||||
>
|
||||
<span
|
||||
v-if="subitem.icon"
|
||||
@@ -29,7 +47,7 @@
|
||||
> </span>
|
||||
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
|
||||
<span
|
||||
v-if="subkey === 'forum' || subkey === 'vocabtrainer' || subitem.children"
|
||||
v-if="hasSecondLevelSubmenu(subitem, subkey)"
|
||||
class="subsubmenu"
|
||||
>▶</span>
|
||||
|
||||
@@ -37,11 +55,16 @@
|
||||
<ul
|
||||
v-if="subkey === 'forum' && forumList.length"
|
||||
class="submenu2"
|
||||
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
|
||||
>
|
||||
<li
|
||||
v-for="forum in forumList"
|
||||
:key="forum.id"
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
@click="handleItem({ action: 'openForum', params: forum.id }, $event)"
|
||||
@keydown.enter.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
|
||||
@keydown.space.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
|
||||
>
|
||||
{{ forum.name }}
|
||||
</li>
|
||||
@@ -51,16 +74,25 @@
|
||||
<ul
|
||||
v-else-if="subkey === 'vocabtrainer' && vocabLanguagesList.length"
|
||||
class="submenu2"
|
||||
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
|
||||
>
|
||||
<li
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
@click="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
|
||||
@keydown.enter.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
|
||||
@keydown.space.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
|
||||
>
|
||||
{{ $t('navigation.m-sprachenlernen.m-vocabtrainer.newLanguage') }}
|
||||
</li>
|
||||
<li
|
||||
v-for="lang in vocabLanguagesList"
|
||||
:key="lang.id"
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
@click="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
|
||||
@keydown.enter.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
|
||||
@keydown.space.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
|
||||
>
|
||||
{{ lang.name }}
|
||||
</li>
|
||||
@@ -70,11 +102,16 @@
|
||||
<ul
|
||||
v-else-if="subitem.children"
|
||||
class="submenu2"
|
||||
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
|
||||
>
|
||||
<li
|
||||
v-for="(subsubitem, subsubkey) in subitem.children"
|
||||
:key="subsubkey"
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
@click="handleItem(subsubitem, $event)"
|
||||
@keydown.enter.prevent="handleItem(subsubitem, $event)"
|
||||
@keydown.space.prevent="handleItem(subsubitem, $event)"
|
||||
>
|
||||
<span
|
||||
v-if="subsubitem.icon"
|
||||
@@ -91,17 +128,29 @@
|
||||
v-if="item.showLoggedinFriends === 1 && friendsList.length"
|
||||
v-for="friend in friendsList"
|
||||
:key="friend.id"
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
|
||||
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
|
||||
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
|
||||
>
|
||||
{{ friend.username }}
|
||||
<ul class="submenu2">
|
||||
<li
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
|
||||
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
|
||||
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
|
||||
>
|
||||
{{ $t('navigation.m-friends.chat') }}
|
||||
</li>
|
||||
<li
|
||||
tabindex="0"
|
||||
role="menuitem"
|
||||
@click="handleItem({ action: 'openProfile', params: friend.id }, $event)"
|
||||
@keydown.enter.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
|
||||
@keydown.space.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
|
||||
>
|
||||
{{ $t('navigation.m-friends.profile') }}
|
||||
</li>
|
||||
@@ -110,12 +159,13 @@
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="right-block">
|
||||
<span @click="accessMailbox" class="mailbox"></span>
|
||||
<button type="button" @click="accessMailbox" class="mailbox" aria-label="Mailbox"></button>
|
||||
<span class="logoutblock">
|
||||
<span class="username">{{ user.username }}</span>
|
||||
<span @click="logout" class="menuitem">
|
||||
<span class="menuitem" @click="logout">
|
||||
{{ $t('navigation.logout') }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -127,6 +177,7 @@
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import { createApp } from 'vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { EventBus } from '@/utils/eventBus.js';
|
||||
|
||||
import RandomChatDialog from '../dialogues/chat/RandomChatDialog.vue';
|
||||
import MultiChatDialog from '../dialogues/chat/MultiChatDialog.vue';
|
||||
@@ -146,7 +197,14 @@ export default {
|
||||
return {
|
||||
forumList: [],
|
||||
friendsList: [],
|
||||
vocabLanguagesList: []
|
||||
vocabLanguagesList: [],
|
||||
expandedMainKey: null,
|
||||
expandedSubKey: null,
|
||||
pinnedMainKey: null,
|
||||
pinnedSubKey: null,
|
||||
suppressHover: false,
|
||||
hoverReleaseTimer: null,
|
||||
isMobileNav: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -156,6 +214,9 @@ export default {
|
||||
menuNeedsUpdate(newVal) {
|
||||
if (newVal) this.loadMenu();
|
||||
},
|
||||
$route() {
|
||||
this.collapseMenus();
|
||||
},
|
||||
socket(newSocket) {
|
||||
if (newSocket) {
|
||||
newSocket.on('forumschanged', this.fetchForums);
|
||||
@@ -171,6 +232,10 @@ export default {
|
||||
this.fetchFriends();
|
||||
this.fetchVocabLanguages();
|
||||
}
|
||||
this.updateViewportState();
|
||||
window.addEventListener('resize', this.updateViewportState);
|
||||
document.addEventListener('click', this.handleDocumentClick);
|
||||
document.addEventListener('keydown', this.handleDocumentKeydown);
|
||||
},
|
||||
beforeUnmount() {
|
||||
const sock = this.socket;
|
||||
@@ -179,10 +244,128 @@ export default {
|
||||
sock.off('friendloginchanged');
|
||||
sock.off('reloadmenu');
|
||||
}
|
||||
window.removeEventListener('resize', this.updateViewportState);
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
document.removeEventListener('keydown', this.handleDocumentKeydown);
|
||||
if (this.hoverReleaseTimer) {
|
||||
clearTimeout(this.hoverReleaseTimer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['loadMenu', 'logout']),
|
||||
|
||||
updateViewportState() {
|
||||
this.isMobileNav = window.innerWidth <= 960;
|
||||
if (!this.isMobileNav) {
|
||||
this.expandedMainKey = null;
|
||||
this.expandedSubKey = null;
|
||||
}
|
||||
},
|
||||
|
||||
isMainExpanded(key) {
|
||||
return this.isMobileNav
|
||||
? this.expandedMainKey === key
|
||||
: this.pinnedMainKey === key;
|
||||
},
|
||||
|
||||
isSubExpanded(key) {
|
||||
return this.isMobileNav
|
||||
? this.expandedSubKey === key
|
||||
: this.pinnedSubKey === key;
|
||||
},
|
||||
|
||||
toggleMain(key) {
|
||||
this.expandedMainKey = this.expandedMainKey === key ? null : key;
|
||||
this.expandedSubKey = null;
|
||||
},
|
||||
|
||||
toggleSub(key) {
|
||||
this.expandedSubKey = this.expandedSubKey === key ? null : key;
|
||||
},
|
||||
|
||||
togglePinnedMain(key) {
|
||||
this.pinnedMainKey = this.pinnedMainKey === key ? null : key;
|
||||
this.pinnedSubKey = null;
|
||||
},
|
||||
|
||||
togglePinnedSub(key) {
|
||||
this.pinnedSubKey = this.pinnedSubKey === key ? null : key;
|
||||
},
|
||||
|
||||
collapseMenus() {
|
||||
this.expandedMainKey = null;
|
||||
this.expandedSubKey = null;
|
||||
this.pinnedMainKey = null;
|
||||
this.pinnedSubKey = null;
|
||||
this.suppressHover = true;
|
||||
if (this.hoverReleaseTimer) {
|
||||
clearTimeout(this.hoverReleaseTimer);
|
||||
}
|
||||
this.hoverReleaseTimer = window.setTimeout(() => {
|
||||
this.suppressHover = false;
|
||||
this.hoverReleaseTimer = null;
|
||||
}, 180);
|
||||
this.$nextTick(() => {
|
||||
if (document.activeElement && typeof document.activeElement.blur === 'function') {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
handleDocumentClick(event) {
|
||||
const root = this.$refs.navRoot;
|
||||
if (!root || root.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.collapseMenus();
|
||||
},
|
||||
|
||||
handleDocumentKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
this.collapseMenus();
|
||||
}
|
||||
},
|
||||
|
||||
hasChildren(item) {
|
||||
if (!item?.children) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(item.children)) {
|
||||
return item.children.length > 0;
|
||||
}
|
||||
|
||||
return Object.keys(item.children).length > 0;
|
||||
},
|
||||
|
||||
hasTopLevelSubmenu(item) {
|
||||
return this.hasChildren(item) || (item?.showLoggedinFriends === 1 && this.friendsList.length > 0);
|
||||
},
|
||||
|
||||
hasSecondLevelSubmenu(subitem, subkey) {
|
||||
if (subkey === 'forum') {
|
||||
return this.forumList.length > 0;
|
||||
}
|
||||
|
||||
if (subkey === 'vocabtrainer') {
|
||||
return this.vocabLanguagesList.length > 0;
|
||||
}
|
||||
|
||||
return this.hasChildren(subitem);
|
||||
},
|
||||
|
||||
isItemActive(item) {
|
||||
if (!item?.path || !this.$route?.path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item.path === '/') {
|
||||
return this.$route.path === '/';
|
||||
}
|
||||
|
||||
return this.$route.path === item.path || this.$route.path.startsWith(`${item.path}/`);
|
||||
},
|
||||
|
||||
openMultiChat() {
|
||||
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
|
||||
const exampleRooms = [
|
||||
@@ -199,6 +382,21 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
accessMailbox() {
|
||||
const openMessages = () => {
|
||||
EventBus.emit('open-falukant-messages');
|
||||
};
|
||||
|
||||
if (this.$route?.path?.startsWith('/falukant')) {
|
||||
openMessages();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router.push({ name: 'FalukantOverview' }).then(() => {
|
||||
window.setTimeout(openMessages, 150);
|
||||
});
|
||||
},
|
||||
|
||||
async fetchForums() {
|
||||
try {
|
||||
const res = await apiClient.get('/api/forum');
|
||||
@@ -250,11 +448,19 @@ export default {
|
||||
* 3) Bei `action`: custom action aufrufen
|
||||
* 4) Sonst: normale Router-Navigation
|
||||
*/
|
||||
handleItem(item, event) {
|
||||
handleItem(item, event, key = null) {
|
||||
event.stopPropagation();
|
||||
|
||||
// 1) nur aufklappen, wenn es echte Untermenüs gibt (nicht bei leerem children wie bei Startseite)
|
||||
if (item.children && Object.keys(item.children).length > 0) return;
|
||||
if (key && this.hasTopLevelSubmenu(item)) {
|
||||
if (this.isMobileNav) {
|
||||
this.toggleMain(key);
|
||||
} else {
|
||||
this.togglePinnedMain(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasChildren(item)) return;
|
||||
|
||||
// 2) view → Dialog/Window
|
||||
if (item.view) {
|
||||
@@ -271,18 +477,38 @@ export default {
|
||||
} else {
|
||||
console.error(`Dialog '${item.class}' gefunden, aber keine open()-Methode verfügbar.`);
|
||||
}
|
||||
this.collapseMenus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) custom action (openForum, openChat, ...)
|
||||
if (item.action && typeof this[item.action] === 'function') {
|
||||
return this[item.action](item.params, event);
|
||||
this[item.action](item.params, event);
|
||||
this.collapseMenus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Standard‑Navigation
|
||||
if (item.path) {
|
||||
this.$router.push(item.path);
|
||||
this.collapseMenus();
|
||||
}
|
||||
},
|
||||
|
||||
handleSubItem(item, subkey, parentKey, event) {
|
||||
event.stopPropagation();
|
||||
const compoundKey = `${parentKey}:${subkey}`;
|
||||
|
||||
if (this.hasSecondLevelSubmenu(item, subkey)) {
|
||||
if (this.isMobileNav) {
|
||||
this.toggleSub(compoundKey);
|
||||
} else {
|
||||
this.togglePinnedSub(compoundKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleItem(item, event);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -291,42 +517,105 @@ export default {
|
||||
<style lang="scss" scoped>
|
||||
@import '../assets/styles.scss';
|
||||
|
||||
nav,
|
||||
nav > ul {
|
||||
.app-navigation,
|
||||
.nav-primary > ul {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: #f8a22b;
|
||||
color: #000;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.app-navigation {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0 auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
flex-wrap: wrap;
|
||||
border-radius: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
|
||||
border-top: 1px solid rgba(93, 64, 55, 0.08);
|
||||
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.46);
|
||||
color: var(--color-text-primary);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.nav-primary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.nav-primary > ul {
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav > ul > li {
|
||||
padding: 0 1em;
|
||||
line-height: 2.5em;
|
||||
transition: background-color 0.25s;
|
||||
.mainmenuitem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.25s, color 0.25s, transform 0.2s, border-color 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
|
||||
nav > ul > li:hover {
|
||||
background-color: #f8a22b;
|
||||
.mainmenuitem:focus-visible,
|
||||
.submenu1 > li:focus-visible,
|
||||
.submenu2 > li:focus-visible,
|
||||
.mailbox:focus-visible,
|
||||
.menuitem:focus-visible {
|
||||
outline: 3px solid rgba(120, 195, 138, 0.34);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.mainmenuitem:hover {
|
||||
background-color: rgba(248, 162, 43, 0.16);
|
||||
border-color: rgba(248, 162, 43, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.mainmenuitem:hover > span {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.mainmenuitem--expanded {
|
||||
background-color: rgba(248, 162, 43, 0.16);
|
||||
border-color: rgba(248, 162, 43, 0.2);
|
||||
}
|
||||
|
||||
.mainmenuitem--active {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-color: rgba(248, 162, 43, 0.22);
|
||||
box-shadow: 0 6px 14px rgba(93, 64, 55, 0.05);
|
||||
}
|
||||
|
||||
.mainmenuitem__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
nav > ul > li:hover > span {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
nav > ul > li:hover > ul {
|
||||
display: inline-block;
|
||||
.mainmenuitem__caret {
|
||||
margin-left: 6px;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(95, 75, 57, 0.7);
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -335,17 +624,29 @@ a {
|
||||
|
||||
.right-block {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-left: 10px;
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
border-left: 1px solid rgba(93, 64, 55, 0.12);
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
|
||||
}
|
||||
|
||||
.logoutblock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.menuitem {
|
||||
cursor: pointer;
|
||||
color: #5D4037;
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mailbox {
|
||||
@@ -353,20 +654,29 @@ a {
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
padding-left: 24px;
|
||||
text-align: left;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 999px;
|
||||
background-color: rgba(120, 195, 138, 0.12);
|
||||
border: 1px solid rgba(93, 64, 55, 0.1);
|
||||
box-shadow: none;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mainmenuitem {
|
||||
position: relative;
|
||||
}
|
||||
.mainmenuitem { position: relative; font-weight: 700; }
|
||||
|
||||
.submenu1 {
|
||||
position: absolute;
|
||||
border: 1px solid #5D4037;
|
||||
background-color: #f8a22b;
|
||||
display: block;
|
||||
border: 1px solid rgba(93, 64, 55, 0.12);
|
||||
background: rgba(255, 252, 247, 0.99);
|
||||
left: 0;
|
||||
top: 2.5em;
|
||||
top: calc(100% + 10px);
|
||||
min-width: 240px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 18px 30px rgba(93, 64, 55, 0.14);
|
||||
max-height: 0;
|
||||
overflow: visible;
|
||||
opacity: 0;
|
||||
@@ -385,16 +695,27 @@ a {
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
.mainmenuitem--expanded .submenu1 {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: max-height 0.25s ease-in-out,
|
||||
opacity 0.05s ease-in-out,
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
.submenu1 > li {
|
||||
padding: 0.5em;
|
||||
line-height: 1em;
|
||||
color: #5D4037;
|
||||
display: block;
|
||||
padding: 0.75em 0.9em;
|
||||
line-height: 1.1em;
|
||||
color: var(--color-text-secondary);
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.submenu1 > li:hover {
|
||||
color: #000;
|
||||
background-color: #f8a22b;
|
||||
color: var(--color-text-primary);
|
||||
background-color: rgba(248, 162, 43, 0.12);
|
||||
}
|
||||
|
||||
.menu-icon,
|
||||
@@ -407,7 +728,7 @@ a {
|
||||
.menu-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 3px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.submenu-icon {
|
||||
@@ -419,10 +740,15 @@ a {
|
||||
|
||||
.submenu2 {
|
||||
position: absolute;
|
||||
background-color: #f8a22b;
|
||||
left: 100%;
|
||||
display: block;
|
||||
background: rgba(255, 252, 247, 0.98);
|
||||
left: calc(100% + 8px);
|
||||
top: 0;
|
||||
border: 1px solid #5D4037;
|
||||
min-width: 230px;
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(71, 52, 35, 0.12);
|
||||
box-shadow: 0 14px 24px rgba(93, 64, 55, 0.12);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
@@ -441,15 +767,43 @@ a {
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
.submenu1__item--expanded .submenu2 {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: max-height 0.25s ease-in-out,
|
||||
opacity 0.05s ease-in-out,
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
.app-navigation--suppress-hover .mainmenuitem:hover .submenu1,
|
||||
.app-navigation--suppress-hover .submenu1 > li:hover .submenu2 {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.submenu1__item--expanded {
|
||||
color: var(--color-text-primary);
|
||||
background-color: rgba(248, 162, 43, 0.08);
|
||||
}
|
||||
|
||||
.submenu2 > li {
|
||||
padding: 0.5em;
|
||||
padding: 0.75em 0.9em;
|
||||
line-height: 1em;
|
||||
color: #5D4037;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.submenu2 > li:hover {
|
||||
color: #000;
|
||||
background-color: #f8a22b;
|
||||
color: var(--color-text-primary);
|
||||
background-color: rgba(120, 195, 138, 0.14);
|
||||
}
|
||||
|
||||
.submenu1 > li:focus-visible,
|
||||
.submenu2 > li:focus-visible {
|
||||
color: var(--color-text-primary);
|
||||
background-color: rgba(248, 162, 43, 0.12);
|
||||
}
|
||||
|
||||
.subsubmenu {
|
||||
@@ -457,4 +811,103 @@ a {
|
||||
font-size: 8pt;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 800;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-navigation {
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
padding: 8px 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-primary,
|
||||
.nav-primary > ul,
|
||||
.right-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-primary {
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.nav-primary > ul {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.right-block {
|
||||
justify-content: space-between;
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
border-left: 0;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(93, 64, 55, 0.1);
|
||||
}
|
||||
|
||||
.logoutblock {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mainmenuitem {
|
||||
min-height: 42px;
|
||||
width: calc(50% - 4px);
|
||||
justify-content: flex-start;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.submenu1,
|
||||
.submenu2 {
|
||||
position: static;
|
||||
min-width: 100%;
|
||||
margin-top: 8px;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.submenu1--open,
|
||||
.submenu2--open {
|
||||
max-height: 1200px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.submenu1 > li,
|
||||
.submenu2 > li {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mailbox {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mainmenuitem {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.right-block {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logoutblock {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
198
frontend/src/components/AppSectionBar.vue
Normal file
198
frontend/src/components/AppSectionBar.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<section v-if="isVisible" class="app-section-bar surface-card">
|
||||
<div class="app-section-bar__copy">
|
||||
<span class="app-section-bar__eyebrow">{{ sectionLabel }}</span>
|
||||
<h1 class="app-section-bar__title">{{ pageTitle }}</h1>
|
||||
</div>
|
||||
<button
|
||||
v-if="backTarget"
|
||||
type="button"
|
||||
class="app-section-bar__back"
|
||||
@click="navigateBack"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const SECTION_LABELS = [
|
||||
{ test: (path) => path.startsWith('/falukant'), label: 'Falukant' },
|
||||
{ test: (path) => path.startsWith('/socialnetwork/vocab'), label: 'Vokabeltrainer' },
|
||||
{ test: (path) => path.startsWith('/socialnetwork/forum'), label: 'Forum' },
|
||||
{ test: (path) => path.startsWith('/socialnetwork'), label: 'Community' },
|
||||
{ test: (path) => path.startsWith('/friends'), label: 'Community' },
|
||||
{ test: (path) => path.startsWith('/settings'), label: 'Einstellungen' },
|
||||
{ test: (path) => path.startsWith('/admin'), label: 'Administration' },
|
||||
{ test: (path) => path.startsWith('/minigames'), label: 'Minispiele' },
|
||||
{ test: (path) => path.startsWith('/personal'), label: 'Persoenlich' },
|
||||
{ test: (path) => path.startsWith('/blogs'), label: 'Blog' }
|
||||
];
|
||||
|
||||
const TITLE_MAP = {
|
||||
Friends: 'Freunde',
|
||||
Guestbook: 'Gaestebuch',
|
||||
'Search users': 'Suche',
|
||||
Gallery: 'Galerie',
|
||||
Forum: 'Forum',
|
||||
ForumTopic: 'Thema',
|
||||
Diary: 'Tagebuch',
|
||||
VocabTrainer: 'Sprachen',
|
||||
VocabNewLanguage: 'Neue Sprache',
|
||||
VocabSubscribe: 'Sprache abonnieren',
|
||||
VocabLanguage: 'Sprache',
|
||||
VocabChapter: 'Kapitel',
|
||||
VocabCourses: 'Kurse',
|
||||
VocabCourse: 'Kurs',
|
||||
VocabLesson: 'Lektion',
|
||||
FalukantCreate: 'Charakter erstellen',
|
||||
FalukantOverview: 'Uebersicht',
|
||||
BranchView: 'Niederlassung',
|
||||
MoneyHistoryView: 'Geldverlauf',
|
||||
FalukantFamily: 'Familie',
|
||||
HouseView: 'Haus',
|
||||
NobilityView: 'Adel',
|
||||
ReputationView: 'Ansehen',
|
||||
ChurchView: 'Kirche',
|
||||
EducationView: 'Bildung',
|
||||
BankView: 'Bank',
|
||||
DirectorView: 'Direktoren',
|
||||
HealthView: 'Gesundheit',
|
||||
PoliticsView: 'Politik',
|
||||
UndergroundView: 'Untergrund',
|
||||
'Personal settings': 'Persoenliche Daten',
|
||||
'View settings': 'Ansicht',
|
||||
'Sexuality settings': 'Sexualitaet',
|
||||
'Flirt settings': 'Flirt',
|
||||
'Account settings': 'Account',
|
||||
Interests: 'Interessen',
|
||||
AdminInterests: 'Interessenverwaltung',
|
||||
AdminUsers: 'Benutzer',
|
||||
AdminUserStatistics: 'Benutzerstatistik',
|
||||
AdminContacts: 'Kontaktanfragen',
|
||||
AdminUserRights: 'Rechte',
|
||||
AdminForums: 'Forumverwaltung',
|
||||
AdminChatRooms: 'Chaträume',
|
||||
AdminFalukantEditUserView: 'Falukant-Nutzer',
|
||||
AdminFalukantMapRegionsView: 'Falukant-Karte',
|
||||
AdminFalukantCreateNPCView: 'NPC erstellen',
|
||||
AdminMinigames: 'Match3-Verwaltung',
|
||||
AdminTaxiTools: 'Taxi-Tools',
|
||||
AdminServicesStatus: 'Service-Status'
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'AppSectionBar',
|
||||
computed: {
|
||||
routePath() {
|
||||
return this.$route?.path || '';
|
||||
},
|
||||
isVisible() {
|
||||
return Boolean(this.$route?.meta?.requiresAuth) && this.routePath !== '/';
|
||||
},
|
||||
sectionLabel() {
|
||||
const found = SECTION_LABELS.find((entry) => entry.test(this.routePath));
|
||||
return found?.label || 'Bereich';
|
||||
},
|
||||
pageTitle() {
|
||||
return TITLE_MAP[this.$route?.name] || this.sectionLabel;
|
||||
},
|
||||
backTarget() {
|
||||
const params = this.$route?.params || {};
|
||||
|
||||
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
|
||||
return `/socialnetwork/vocab/courses/${params.courseId}`;
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/socialnetwork/vocab/') && params.chapterId && params.languageId) {
|
||||
return `/socialnetwork/vocab/${params.languageId}`;
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/socialnetwork/vocab/new') || this.routePath.startsWith('/socialnetwork/vocab/subscribe')) {
|
||||
return '/socialnetwork/vocab';
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.courseId) {
|
||||
return '/socialnetwork/vocab/courses';
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/admin/users/statistics')) {
|
||||
return '/admin/users';
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/falukant/') && this.routePath !== '/falukant/home') {
|
||||
return '/falukant/home';
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/settings/') && this.routePath !== '/settings/personal') {
|
||||
return '/settings/personal';
|
||||
}
|
||||
|
||||
if (this.routePath.startsWith('/admin/') && this.routePath !== '/admin/users') {
|
||||
return '/admin/users';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
navigateBack() {
|
||||
if (this.backTarget) {
|
||||
this.$router.push(this.backTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-section-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 240, 231, 0.94));
|
||||
}
|
||||
|
||||
.app-section-bar__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-section-bar__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-section-bar__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.15rem, 1.6vw, 1.6rem);
|
||||
}
|
||||
|
||||
.app-section-bar__back {
|
||||
flex: 0 0 auto;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-section-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-section-bar__back {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,38 @@
|
||||
<template>
|
||||
<div ref="container" class="character-3d-container"></div>
|
||||
<div class="character-3d-shell">
|
||||
<div v-show="!showFallback" ref="container" class="character-3d-container"></div>
|
||||
<img
|
||||
v-if="showFallback"
|
||||
class="character-fallback"
|
||||
:src="fallbackImageSrc"
|
||||
:alt="`Character ${actualGender}`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { markRaw } from 'vue';
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
|
||||
import { getApiBaseURL } from '@/utils/axios.js';
|
||||
|
||||
/** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */
|
||||
const MODELS_API_PATH = '/api/models/3d/falukant/characters';
|
||||
let threeRuntimePromise = null;
|
||||
|
||||
async function loadThreeRuntime() {
|
||||
if (!threeRuntimePromise) {
|
||||
threeRuntimePromise = Promise.all([
|
||||
import('three'),
|
||||
import('three/addons/loaders/GLTFLoader.js'),
|
||||
import('three/addons/loaders/DRACOLoader.js')
|
||||
]).then(([THREE, { GLTFLoader }, { DRACOLoader }]) => ({
|
||||
THREE,
|
||||
GLTFLoader,
|
||||
DRACOLoader
|
||||
}));
|
||||
}
|
||||
|
||||
return threeRuntimePromise;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Character3D',
|
||||
@@ -40,8 +62,10 @@ export default {
|
||||
model: null,
|
||||
animationId: null,
|
||||
mixer: null,
|
||||
clock: markRaw(new THREE.Clock()),
|
||||
baseYPosition: 0 // Basis-Y-Position für Animation
|
||||
clock: null,
|
||||
baseYPosition: 0,
|
||||
showFallback: false,
|
||||
threeRuntime: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -93,34 +117,50 @@ export default {
|
||||
const base = getApiBaseURL();
|
||||
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
|
||||
return `${prefix}/${this.actualGender}_${age}y.glb`;
|
||||
},
|
||||
fallbackImageSrc() {
|
||||
return this.actualGender === 'female'
|
||||
? '/images/mascot/mascot_female.png'
|
||||
: '/images/mascot/mascot_male.png';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
actualGender() {
|
||||
this.loadModel();
|
||||
async actualGender() {
|
||||
await this.loadModel();
|
||||
},
|
||||
ageGroup() {
|
||||
this.loadModel();
|
||||
async ageGroup() {
|
||||
await this.loadModel();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init3D();
|
||||
this.loadModel();
|
||||
async mounted() {
|
||||
await this.init3D();
|
||||
await this.loadModel();
|
||||
this.animate();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.cleanup();
|
||||
},
|
||||
methods: {
|
||||
init3D() {
|
||||
async ensureThreeRuntime() {
|
||||
if (!this.threeRuntime) {
|
||||
this.threeRuntime = markRaw(await loadThreeRuntime());
|
||||
}
|
||||
|
||||
return this.threeRuntime;
|
||||
},
|
||||
|
||||
async init3D() {
|
||||
const container = this.$refs.container;
|
||||
if (!container) return;
|
||||
this.showFallback = false;
|
||||
const { THREE } = await this.ensureThreeRuntime();
|
||||
this.clock = markRaw(new THREE.Clock());
|
||||
|
||||
// Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
|
||||
this.scene = markRaw(new THREE.Scene());
|
||||
if (!this.noBackground) {
|
||||
this.scene.background = new THREE.Color(0xf0f0f0);
|
||||
this.loadBackground();
|
||||
await this.loadBackground();
|
||||
}
|
||||
|
||||
// Camera erstellen
|
||||
@@ -159,7 +199,8 @@ export default {
|
||||
window.addEventListener('resize', this.onWindowResize);
|
||||
},
|
||||
|
||||
loadBackground() {
|
||||
async loadBackground() {
|
||||
const { THREE } = await this.ensureThreeRuntime();
|
||||
// Optimierte Versionen (512×341, ~130 KB); Originale ~3 MB
|
||||
const backgrounds = ['bg1_opt.png', 'bg2_opt.png'];
|
||||
const randomBg = backgrounds[Math.floor(Math.random() * backgrounds.length)];
|
||||
@@ -187,6 +228,7 @@ export default {
|
||||
|
||||
async loadModel() {
|
||||
if (!this.scene) return;
|
||||
const { THREE, GLTFLoader, DRACOLoader } = await this.ensureThreeRuntime();
|
||||
|
||||
// Altes Modell entfernen
|
||||
if (this.model) {
|
||||
@@ -301,12 +343,17 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading 3D model:', error);
|
||||
this.showFallback = true;
|
||||
}
|
||||
},
|
||||
|
||||
animate() {
|
||||
this.animationId = requestAnimationFrame(this.animate);
|
||||
|
||||
if (!this.clock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta();
|
||||
|
||||
// Animation-Mixer aktualisieren
|
||||
@@ -375,10 +422,25 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-3d-shell {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.character-3d-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.character-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center bottom;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.action)" class="dialog-button">
|
||||
<button v-for="button in buttons" :key="button.text" @click="buttonClick(button.action)" class="dialog-button" :disabled="button.disabled">
|
||||
{{ isTitleTranslated ? $t(button.text) : button.text }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -142,6 +142,9 @@ export default {
|
||||
return this.minimized;
|
||||
},
|
||||
startDragging(event) {
|
||||
if (window.innerWidth <= 760) {
|
||||
return;
|
||||
}
|
||||
this.isDragging = true;
|
||||
const dialog = this.$refs.dialog;
|
||||
this.dragOffsetX = event.clientX - dialog.offsetLeft;
|
||||
@@ -186,7 +189,8 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: rgba(24, 18, 11, 0.44);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dialog-overlay.non-modal {
|
||||
@@ -195,14 +199,17 @@ export default {
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-medium);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(93, 64, 55, 0.12);
|
||||
pointer-events: all;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog.minimized {
|
||||
@@ -214,64 +221,112 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 5px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background-color: var(--color-primary-orange);
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(93, 64, 55, 0.1);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 162, 43, 0.16) 0%, rgba(255, 255, 255, 0.56) 100%);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.dialog-icon {
|
||||
padding: 2px 5px 0 0;
|
||||
padding: 2px 6px 0 0;
|
||||
}
|
||||
|
||||
.dialog-icon img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
flex-grow: 1;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dialog-close,
|
||||
.dialog-minimize {
|
||||
cursor: pointer;
|
||||
font-size: 1.5em;
|
||||
margin-left: 10px;
|
||||
font-size: 1.1rem;
|
||||
margin-left: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
color: var(--color-text-secondary);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.dialog-close:hover,
|
||||
.dialog-minimize:hover {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
flex-grow: 1;
|
||||
padding: 20px;
|
||||
padding: 18px 20px;
|
||||
overflow-y: auto;
|
||||
display: var(--dialog-display);
|
||||
color: var(--color-text-primary);
|
||||
&[style*="--dialog-display: flex"] {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
dialog-footer {
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
gap: 10px;
|
||||
padding: 14px 20px 18px;
|
||||
border-top: 1px solid rgba(93, 64, 55, 0.08);
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
margin-left: 10px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
background: var(--color-primary-orange);
|
||||
color: #000000;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition: background 0.02s;
|
||||
margin-left: 0;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.dialog-button:hover {
|
||||
background: #FFF4F0;
|
||||
color: #5D4037;
|
||||
border: 1px solid #5D4037;
|
||||
color: #2b1f14;
|
||||
}
|
||||
|
||||
.is-active {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.dialog {
|
||||
width: calc(100vw - 16px) !important;
|
||||
max-width: calc(100vw - 16px);
|
||||
height: auto !important;
|
||||
max-height: calc(100dvh - 16px);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
cursor: default;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,7 +105,8 @@ export default {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: rgba(24, 18, 11, 0.44);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.dialog-overlay.non-modal {
|
||||
@@ -114,12 +115,14 @@ export default {
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-medium);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(93, 64, 55, 0.12);
|
||||
pointer-events: all;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog.minimized {
|
||||
@@ -131,9 +134,9 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background-color: var(--color-primary-orange);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(93, 64, 55, 0.1);
|
||||
background: linear-gradient(180deg, rgba(248, 162, 43, 0.16) 0%, rgba(255, 255, 255, 0.56) 100%);
|
||||
}
|
||||
|
||||
.dialog-icon {
|
||||
@@ -142,42 +145,46 @@ export default {
|
||||
|
||||
.dialog-title {
|
||||
flex-grow: 1;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dialog-close,
|
||||
.dialog-minimize {
|
||||
cursor: pointer;
|
||||
font-size: 1.5em;
|
||||
margin-left: 10px;
|
||||
font-size: 1.1rem;
|
||||
margin-left: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
flex-grow: 1;
|
||||
padding: 20px;
|
||||
padding: 18px 20px;
|
||||
overflow-y: auto;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid #ddd;
|
||||
padding: 14px 20px 18px;
|
||||
border-top: 1px solid rgba(93, 64, 55, 0.08);
|
||||
background: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
margin-left: 10px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s;
|
||||
margin-left: 0;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.dialog-button:hover {
|
||||
background: #0056b3;
|
||||
color: #2b1f14;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,6 +85,7 @@ import InputNumberWidget from '@/components/form/InputNumberWidget.vue';
|
||||
import FloatInputWidget from '@/components/form/FloatInputWidget.vue';
|
||||
import CheckboxWidget from '@/components/form/CheckboxWidget.vue';
|
||||
import MultiselectWidget from '@/components/form/MultiselectWidget.vue';
|
||||
import { showApiError, showError } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: "SettingsWidget",
|
||||
@@ -158,7 +159,7 @@ export default {
|
||||
// Prüfe ob das Setting unveränderlich ist
|
||||
const setting = this.settings.find(s => s.id === settingId);
|
||||
if (setting && setting.immutable && setting.value) {
|
||||
alert(this.$t('settings.immutable.tooltip'));
|
||||
showError(this, this.$t('settings.immutable.tooltip'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -172,9 +173,7 @@ export default {
|
||||
this.fetchSettings();
|
||||
} catch (err) {
|
||||
console.error('Error updating setting:', err);
|
||||
if (err.response && err.response.data && err.response.data.error) {
|
||||
alert(err.response.data.error);
|
||||
}
|
||||
showApiError(this, err, 'Aenderung konnte nicht gespeichert werden.');
|
||||
}
|
||||
},
|
||||
languagesList() {
|
||||
@@ -208,6 +207,7 @@ export default {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error updating visibility:', err);
|
||||
showApiError(this, err, 'Sichtbarkeit konnte nicht aktualisiert werden.');
|
||||
}
|
||||
},
|
||||
openContactDialog() {
|
||||
|
||||
@@ -23,11 +23,19 @@
|
||||
<img :src="'/images/icons/falukant/relationship-' + item.image + '.png'" class="relationship-icon" :title="$t(`falukant.statusbar.${item.key}`)" />
|
||||
</div>
|
||||
</template>
|
||||
<span v-if="statusItems.length > 0 && menu.falukant && menu.falukant.children">
|
||||
<div
|
||||
v-if="statusItems.length > 0 && menu.falukant && menu.falukant.children"
|
||||
class="quick-access"
|
||||
>
|
||||
<template v-for="(menuItem, key) in menu.falukant.children" :key="menuItem.id" >
|
||||
<img :src="'/images/icons/falukant/shortmap/' + key + '.png'" class="menu-icon" @click="openPage(menuItem)" :title="$t(`navigation.m-falukant.${key}`)" />
|
||||
<img
|
||||
:src="'/images/icons/falukant/shortmap/' + key + '.png'"
|
||||
class="menu-icon"
|
||||
@click="openPage(menuItem)"
|
||||
:title="$t(`navigation.m-falukant.${key}`)"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<MessagesDialog ref="msgs" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -35,6 +43,7 @@
|
||||
<script>
|
||||
import { mapState, mapGetters } from "vuex";
|
||||
import apiClient from "@/utils/axios.js";
|
||||
import { EventBus } from '@/utils/eventBus.js';
|
||||
import MessagesDialog from './MessagesDialog.vue';
|
||||
|
||||
export default {
|
||||
@@ -86,10 +95,12 @@ export default {
|
||||
// Socket.IO (Backend notifyUser) – Hauptkanal für Falukant-Events
|
||||
this.setupSocketListeners();
|
||||
this.setupDaemonListeners();
|
||||
EventBus.on('open-falukant-messages', this.openMessages);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.teardownSocketListeners();
|
||||
this.teardownDaemonListeners();
|
||||
EventBus.off('open-falukant-messages', this.openMessages);
|
||||
},
|
||||
methods: {
|
||||
preloadQuickAccessImages() {
|
||||
@@ -220,14 +231,21 @@ export default {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f4f4f4;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
width: calc(100% + 40px);
|
||||
flex-wrap: wrap;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(247, 238, 224, 0.98) 100%);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
gap: 1.2em;
|
||||
margin: -21px -20px 1.5em -20px;
|
||||
position: fixed;
|
||||
padding: 0.55rem 0.9rem;
|
||||
margin: 0 0 1.5em 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.status-item {
|
||||
@@ -235,6 +253,19 @@ export default {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.68);
|
||||
border: 1px solid rgba(93, 64, 55, 0.08);
|
||||
}
|
||||
|
||||
.quick-access {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
@@ -254,6 +285,8 @@ export default {
|
||||
.menu-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
padding: 4px 2px 0 0;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,31 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
falukantData() {
|
||||
const d = this.data;
|
||||
if (d && typeof d === 'object' && 'characterName' in d && 'money' in d) return d;
|
||||
// normalize incoming API payload: accept both camelCase and snake_case
|
||||
const raw = this.data;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
const pick = (obj, camel, snake) => {
|
||||
if (camel in obj && obj[camel] !== undefined) return obj[camel];
|
||||
if (snake in obj && obj[snake] !== undefined) return obj[snake];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalized = {
|
||||
characterName: pick(raw, 'characterName', 'character_name') ?? pick(raw, 'nameWithoutTitle', 'name_without_title'),
|
||||
nameWithoutTitle: pick(raw, 'nameWithoutTitle', 'name_without_title'),
|
||||
titleLabelTr: pick(raw, 'titleLabelTr', 'title_label_tr'),
|
||||
gender: pick(raw, 'gender', 'gender'),
|
||||
age: pick(raw, 'age', 'age'),
|
||||
money: pick(raw, 'money', 'money'),
|
||||
unreadNotificationsCount: pick(raw, 'unreadNotificationsCount', 'unread_notifications_count'),
|
||||
childrenCount: pick(raw, 'childrenCount', 'children_count'),
|
||||
// keep all original keys as fallback for any other usage
|
||||
...raw
|
||||
};
|
||||
|
||||
// sanity: require at least a name and money
|
||||
if ((normalized.characterName || normalized.nameWithoutTitle) && (normalized.money !== undefined)) return normalized;
|
||||
return null;
|
||||
},
|
||||
falukantDisplayName() {
|
||||
@@ -46,8 +69,8 @@ export default {
|
||||
if (g == null || g === '') return '—';
|
||||
|
||||
// Altersabhängige, (auf Wunsch) altertümlichere Bezeichnungen
|
||||
const age = Number(this.falukantData?.age);
|
||||
const group = this._getAgeGroupKey(age);
|
||||
const years = this._ageYearsFromWidgetValue(this.falukantData?.age);
|
||||
const group = years == null ? null : this._getAgeGroupKey(years);
|
||||
if (group && (g === 'female' || g === 'male')) {
|
||||
const key = `falukant.genderAge.${g}.${group}`;
|
||||
const t = this.$t(key);
|
||||
@@ -62,18 +85,37 @@ export default {
|
||||
falukantAgeLabel() {
|
||||
const ageValue = this.falukantData?.age;
|
||||
if (ageValue == null) return '—';
|
||||
const numAge = Number(ageValue);
|
||||
return `${numAge} ${this.$t('falukant.overview.metadata.years')}`;
|
||||
const years = this._ageYearsFromWidgetValue(ageValue);
|
||||
if (years == null) return '—';
|
||||
return `${years} ${this.$t('falukant.overview.metadata.years')}`;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Backend liefert für Falukant das Alter als (Spiel-)Tage.
|
||||
* In diesem Spiel entspricht 1 (Spiel-)Tag einem Jahr, damit die Alterung spielbar schnell ist.
|
||||
*
|
||||
* Daher ist der übergebene Tageswert direkt das Alter in (Spiel-)Jahren.
|
||||
*/
|
||||
_ageYearsFromWidgetValue(ageValue) {
|
||||
const n = Number(ageValue);
|
||||
if (Number.isNaN(n)) return null;
|
||||
|
||||
// Spiel-Zeit: 1 Tag = 1 Jahr
|
||||
const years = Math.floor(n);
|
||||
return Number.isFinite(years) ? years : null;
|
||||
},
|
||||
_getAgeGroupKey(age) {
|
||||
const a = Number(age);
|
||||
if (Number.isNaN(a)) return null;
|
||||
|
||||
// Pro Sprache konfigurierbare Schwellenwerte aus i18n.
|
||||
// Format: "key:maxAge|key2:maxAge2|..." (maxAge exklusiv, letzte Gruppe sollte hoch gesetzt sein)
|
||||
const raw = this.$t('falukant.genderAge.ageGroups');
|
||||
// Achtung: vue-i18n kann Strings mit `|` als Plural/Choice-Format interpretieren.
|
||||
// Dann würde `$t(...)` nur bis zum ersten `|` liefern (z.B. "toddler:4").
|
||||
// Deshalb lesen wir den Rohwert direkt aus den registrierten Messages.
|
||||
const msgAgeGroups = this?.$i18n?.messages?.[this?.$i18n?.locale]?.falukant?.genderAge?.ageGroups;
|
||||
const raw = typeof msgAgeGroups === 'string' ? msgAgeGroups : this.$t('falukant.genderAge.ageGroups');
|
||||
const parsed = typeof raw === 'string' ? raw : '';
|
||||
const rules = parsed.split('|')
|
||||
.map(part => part.trim())
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<template>
|
||||
<DialogWidget ref="dialog" title="passwordReset.title" :isTitleTranslated="true" :show-close=true :buttons="buttons" @close="closeDialog" @reset="resetPassword" name="PasswordReset">
|
||||
<div>
|
||||
<label>{{ $t("passwordReset.email") }} <input type="email" v-model="email" required /></label>
|
||||
<div class="form-stack">
|
||||
<div class="form-field">
|
||||
<label for="password-reset-email">{{ $t("passwordReset.email") }}</label>
|
||||
<input id="password-reset-email" type="email" v-model="email" required :class="{ 'field-error': emailTouched && !isEmailValid }" />
|
||||
<span class="form-hint">Wir senden den Link an die hinterlegte E-Mail-Adresse.</span>
|
||||
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gueltige E-Mail-Adresse eingeben.</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
</template>
|
||||
@@ -9,6 +14,7 @@
|
||||
<script>
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import { showApiError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'PasswordResetDialog',
|
||||
@@ -18,9 +24,21 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
buttons: [{ text: 'passwordReset.reset', action: 'reset' }]
|
||||
emailTouched: false,
|
||||
buttons: [{ text: 'passwordReset.reset', action: 'reset', disabled: true }]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isEmailValid() {
|
||||
return /\S+@\S+\.\S+/.test(this.email);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
email() {
|
||||
this.emailTouched = true;
|
||||
this.buttons[0].disabled = !this.isEmailValid;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
open() {
|
||||
this.$refs.dialog.open();
|
||||
@@ -29,15 +47,18 @@ export default {
|
||||
this.$refs.dialog.close();
|
||||
},
|
||||
async resetPassword() {
|
||||
if (!this.isEmailValid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.post('/api/users/requestPasswordReset', {
|
||||
email: this.email
|
||||
});
|
||||
this.$refs.dialog.close();
|
||||
alert(this.$t("passwordReset.success"));
|
||||
showSuccess(this, 'tr:passwordReset.success');
|
||||
} catch (error) {
|
||||
console.error('Error resetting password:', error);
|
||||
alert(this.$t("passwordReset.failure"));
|
||||
showApiError(this, error, 'tr:passwordReset.failure');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
<DialogWidget ref="dialog" title="register.title" :show-close="true" :buttons="buttons" :modal="true"
|
||||
@close="closeDialog" @register="register" width="35em" height="33em" name="RegisterDialog"
|
||||
:isTitleTranslated="true">
|
||||
<div class="form-content">
|
||||
<div>
|
||||
<label>{{ $t("register.email") }}<input type="email" v-model="email" /></label>
|
||||
<div class="form-content form-stack">
|
||||
<div class="form-field">
|
||||
<label for="register-email">{{ $t("register.email") }}</label>
|
||||
<input id="register-email" type="email" v-model="email" :class="{ 'field-error': emailTouched && !isEmailValid }" />
|
||||
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gueltige E-Mail-Adresse eingeben.</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ $t("register.username") }}<input type="text" v-model="username" /></label>
|
||||
<div class="form-field">
|
||||
<label for="register-username">{{ $t("register.username") }}</label>
|
||||
<input id="register-username" type="text" v-model="username" :class="{ 'field-error': usernameTouched && !isUsernameValid }" />
|
||||
<span v-if="usernameTouched && !isUsernameValid" class="form-error">Der Benutzername sollte mindestens 3 Zeichen haben.</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ $t("register.password") }}<input type="password" v-model="password" /></label>
|
||||
<div class="form-field">
|
||||
<label for="register-password">{{ $t("register.password") }}</label>
|
||||
<input id="register-password" type="password" v-model="password" :class="{ 'field-error': passwordTouched && !isPasswordValid }" />
|
||||
<span class="form-hint">Mindestens 8 Zeichen.</span>
|
||||
<span v-if="passwordTouched && !isPasswordValid" class="form-error">Das Passwort ist noch zu kurz.</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ $t("register.repeatPassword") }}<input type="password" v-model="repeatPassword" /></label>
|
||||
<div class="form-field">
|
||||
<label for="register-repeat-password">{{ $t("register.repeatPassword") }}</label>
|
||||
<input id="register-repeat-password" type="password" v-model="repeatPassword" :class="{ 'field-error': repeatPasswordTouched && !doPasswordsMatch }" />
|
||||
<span v-if="repeatPasswordTouched && !doPasswordsMatch" class="form-error">Die Passwoerter stimmen nicht ueberein.</span>
|
||||
</div>
|
||||
<SelectDropdownWidget labelTr="settings.personal.label.language" :v-model="language"
|
||||
tooltipTr="settings.personal.tooltip.language" :list="languages" :value="language" />
|
||||
@@ -26,6 +35,7 @@ import { mapActions } from 'vuex';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import SelectDropdownWidget from '@/components/form/SelectDropdownWidget.vue';
|
||||
import { showApiError, showError } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'RegisterDialog',
|
||||
@@ -41,6 +51,10 @@ export default {
|
||||
repeatPassword: '',
|
||||
language: null,
|
||||
languages: [],
|
||||
emailTouched: false,
|
||||
usernameTouched: false,
|
||||
passwordTouched: false,
|
||||
repeatPasswordTouched: false,
|
||||
buttons: [
|
||||
{ text: 'register.close', action: 'close' },
|
||||
{ text: 'register.register', action: 'register', disabled: !this.canRegister }
|
||||
@@ -48,11 +62,35 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isEmailValid() {
|
||||
return /\S+@\S+\.\S+/.test(this.email);
|
||||
},
|
||||
isUsernameValid() {
|
||||
return this.username.trim().length >= 3;
|
||||
},
|
||||
isPasswordValid() {
|
||||
return this.password.length >= 8;
|
||||
},
|
||||
doPasswordsMatch() {
|
||||
return Boolean(this.password) && this.password === this.repeatPassword;
|
||||
},
|
||||
canRegister() {
|
||||
return this.password && this.repeatPassword && this.password === this.repeatPassword;
|
||||
return this.isEmailValid && this.isUsernameValid && this.isPasswordValid && this.doPasswordsMatch && this.language;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
email() {
|
||||
this.emailTouched = true;
|
||||
},
|
||||
username() {
|
||||
this.usernameTouched = true;
|
||||
},
|
||||
password() {
|
||||
this.passwordTouched = true;
|
||||
},
|
||||
repeatPassword() {
|
||||
this.repeatPasswordTouched = true;
|
||||
},
|
||||
canRegister(newValue) {
|
||||
this.buttons[1].disabled = !newValue;
|
||||
}
|
||||
@@ -82,7 +120,7 @@ export default {
|
||||
},
|
||||
async register() {
|
||||
if (!this.canRegister) {
|
||||
this.$root.$refs.errrorDialog.open('tr:register.passwordMismatch');
|
||||
showError(this, 'tr:register.passwordMismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,14 +137,14 @@ export default {
|
||||
this.$refs.dialog.close();
|
||||
this.$router.push('/activate');
|
||||
} else {
|
||||
this.$root.$refs.errrorDialog.open("tr:register.failure");
|
||||
showError(this, 'tr:register.failure');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 409) {
|
||||
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
|
||||
showError(this, `tr:register.${error.response.data.error}`);
|
||||
} else {
|
||||
console.error('Error registering user:', error);
|
||||
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
|
||||
showApiError(this, error, 'tr:register.failure');
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,21 +163,11 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-content>div {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
input[type="email"],
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.5em;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
<template>
|
||||
<DialogWidget ref="dialog" :title="$t('chat.multichat.title')" :modal="true" width="40em" height="32em" name="MultiChat">
|
||||
<div class="multi-chat-top">
|
||||
<select v-model="selectedRoom" class="room-select">
|
||||
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
|
||||
</select>
|
||||
<div class="options-popdown">
|
||||
<label>
|
||||
<input type="checkbox" v-model="autoscroll" />
|
||||
{{ $t('chat.multichat.autoscroll') }}
|
||||
</label>
|
||||
<!-- Weitere Optionen können hier ergänzt werden -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="multi-chat-output" ref="output" @mouseenter="mouseOverOutput = true" @mouseleave="mouseOverOutput = false">
|
||||
<div v-for="msg in messages" :key="msg.id" class="chat-message">
|
||||
<span class="user">{{ msg.user }}:</span> <span class="text">{{ msg.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="multi-chat-input">
|
||||
<input v-model="input" @keyup.enter="sendMessage" class="chat-input" :placeholder="$t('chat.multichat.placeholder')" />
|
||||
<button @click="sendMessage" class="send-btn">{{ $t('chat.multichat.send') }}</button>
|
||||
<button @click="shout" class="mini-btn">{{ $t('chat.multichat.shout') }}</button>
|
||||
<button @click="action" class="mini-btn">{{ $t('chat.multichat.action') }}</button>
|
||||
<button @click="roll" class="mini-btn">{{ $t('chat.multichat.roll') }}</button>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
|
||||
export default {
|
||||
name: 'MultiChat',
|
||||
components: { DialogWidget },
|
||||
data() {
|
||||
return {
|
||||
rooms: [],
|
||||
selectedRoom: null,
|
||||
autoscroll: true,
|
||||
mouseOverOutput: false,
|
||||
messages: [],
|
||||
input: ''
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
messages() {
|
||||
this.$nextTick(this.handleAutoscroll);
|
||||
},
|
||||
autoscroll(val) {
|
||||
if (val) this.handleAutoscroll();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
open(rooms = []) {
|
||||
this.rooms = rooms;
|
||||
this.selectedRoom = rooms.length ? rooms[0].id : null;
|
||||
this.autoscroll = true;
|
||||
this.messages = [];
|
||||
this.input = '';
|
||||
this.$refs.dialog.open();
|
||||
},
|
||||
handleAutoscroll() {
|
||||
if (this.autoscroll && !this.mouseOverOutput) {
|
||||
const out = this.$refs.output;
|
||||
if (out) out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
},
|
||||
sendMessage() {
|
||||
if (!this.input.trim()) return;
|
||||
this.messages.push({ id: Date.now(), user: 'Ich', text: this.input });
|
||||
this.input = '';
|
||||
},
|
||||
shout() {
|
||||
// Schreien-Logik
|
||||
},
|
||||
action() {
|
||||
// Aktion-Logik
|
||||
},
|
||||
roll() {
|
||||
// Würfeln-Logik
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.multi-chat-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.room-select {
|
||||
min-width: 10em;
|
||||
}
|
||||
.options-popdown {
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
padding: 0.3em 0.8em;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.multi-chat-output {
|
||||
background: #222;
|
||||
color: #fff;
|
||||
height: 16em;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5em;
|
||||
padding: 0.7em;
|
||||
border-radius: 4px;
|
||||
font-size: 1em;
|
||||
}
|
||||
.chat-message {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
.user {
|
||||
font-weight: bold;
|
||||
color: #90caf9;
|
||||
}
|
||||
.multi-chat-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 0.4em 0.7em;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #bbb;
|
||||
}
|
||||
.send-btn {
|
||||
padding: 0.3em 1.1em;
|
||||
border-radius: 3px;
|
||||
background: #1976d2;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-btn {
|
||||
padding: 0.2em 0.7em;
|
||||
font-size: 0.95em;
|
||||
border-radius: 3px;
|
||||
background: #eee;
|
||||
color: #333;
|
||||
border: 1px solid #bbb;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -3,9 +3,14 @@
|
||||
@close="onDialogClose" width="75vw" height="75vh" name="MultiChatDialog" icon="multichat24.png">
|
||||
<div class="dialog-widget-content">
|
||||
<div class="multi-chat-top">
|
||||
<div class="room-left-controls">
|
||||
<select v-model="selectedRoom" class="room-select">
|
||||
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
|
||||
</select>
|
||||
<button class="create-room-toggle-btn" type="button" @click="toggleRoomCreatePanel">
|
||||
{{ showRoomCreatePanel ? $t('chat.multichat.createRoom.toggleShowChat') : $t('chat.multichat.createRoom.toggleCreateRoom') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="right-controls">
|
||||
<div class="status" :class="statusType">
|
||||
<span class="dot"></span>
|
||||
@@ -24,14 +29,14 @@
|
||||
</label>
|
||||
<div class="opts-divider" v-if="isAdmin"></div>
|
||||
<div class="opts-row" v-if="isAdmin">
|
||||
<button class="opts-btn" type="button" @click="reloadRoomsAdmin">Räume neu laden</button>
|
||||
<button class="opts-btn" type="button" @click="reloadRoomsAdmin">{{ $t('chat.multichat.reloadRooms') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showColorPicker" class="multi-chat-body">
|
||||
<div class="multi-chat-output" ref="output" @mouseenter="mouseOverOutput = true"
|
||||
<div v-if="!showRoomCreatePanel" class="multi-chat-output" ref="output" @mouseenter="mouseOverOutput = true"
|
||||
@mouseleave="mouseOverOutput = false">
|
||||
<div v-for="msg in messages" :key="msg.id" class="chat-message">
|
||||
<template v-if="msg.type === 'scream'">
|
||||
@@ -53,6 +58,104 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="room-create-panel">
|
||||
<div class="room-create-title">{{ $t('chat.multichat.createRoom.title') }}</div>
|
||||
<div class="room-create-grid">
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.roomName') }} *
|
||||
<input v-model.trim="roomCreateForm.roomName" type="text" :placeholder="$t('chat.multichat.createRoom.placeholders.roomName')"
|
||||
:class="{ 'invalid-input': roomCreateValidation.roomName }" />
|
||||
<span v-if="roomCreateValidation.roomName" class="room-create-error">{{ roomCreateValidation.roomName }}</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.visibility') }}
|
||||
<select v-model="roomCreateForm.visibility">
|
||||
<option value="">{{ $t('chat.multichat.createRoom.options.none') }}</option>
|
||||
<option value="public">{{ $t('chat.multichat.createRoom.options.visibilityPublic') }}</option>
|
||||
<option value="private">{{ $t('chat.multichat.createRoom.options.visibilityPrivate') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.gender') }}
|
||||
<select v-model="roomCreateForm.gender">
|
||||
<option value="">{{ $t('chat.multichat.createRoom.options.none') }}</option>
|
||||
<option value="m">{{ $t('chat.multichat.createRoom.options.genderMale') }}</option>
|
||||
<option value="f">{{ $t('chat.multichat.createRoom.options.genderFemale') }}</option>
|
||||
<option value="any">{{ $t('chat.multichat.createRoom.options.genderAny') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.minAge') }}
|
||||
<input v-model.number="roomCreateForm.minAge" type="number" min="0"
|
||||
:class="{ 'invalid-input': roomCreateValidation.minAge }" />
|
||||
<span v-if="roomCreateValidation.minAge" class="room-create-error">{{ roomCreateValidation.minAge }}</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.maxAge') }}
|
||||
<input v-model.number="roomCreateForm.maxAge" type="number" min="0"
|
||||
:class="{ 'invalid-input': roomCreateValidation.maxAge }" />
|
||||
<span v-if="roomCreateValidation.maxAge" class="room-create-error">{{ roomCreateValidation.maxAge }}</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.password') }}
|
||||
<input v-model.trim="roomCreateForm.password" type="text" :placeholder="$t('chat.multichat.createRoom.placeholders.password')"
|
||||
:class="{ 'invalid-input': roomCreateValidation.password }" />
|
||||
<span v-if="roomCreateValidation.password" class="room-create-error">{{ roomCreateValidation.password }}</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.rightId') }}
|
||||
<select v-model="roomCreateForm.rightId" :class="{ 'invalid-input': roomCreateValidation.rightId }">
|
||||
<option :value="null">{{ $t('chat.multichat.createRoom.options.none') }}</option>
|
||||
<option v-for="right in roomCreateRights" :key="`right-${right.id}`" :value="right.id">
|
||||
{{ getUserRightLabel(right) }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="roomCreateValidation.rightId" class="room-create-error">{{ roomCreateValidation.rightId }}</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('chat.multichat.createRoom.labels.typeId') }}
|
||||
<select v-model="roomCreateForm.typeId" :class="{ 'invalid-input': roomCreateValidation.typeId }">
|
||||
<option :value="null">{{ $t('chat.multichat.createRoom.options.none') }}</option>
|
||||
<option v-for="roomType in roomCreateTypes" :key="`type-${roomType.id}`" :value="roomType.id">
|
||||
{{ getRoomTypeLabel(roomType) }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="roomCreateValidation.typeId" class="room-create-error">{{ roomCreateValidation.typeId }}</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="roomCreateForm.friendsOnly" />
|
||||
{{ $t('chat.multichat.createRoom.labels.friendsOnly') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="room-create-actions">
|
||||
<button type="button" class="send-btn" @click="sendCreateRoomCommand" :disabled="!canSendRoomCreate">{{ $t('chat.multichat.createRoom.actions.create') }}</button>
|
||||
<button type="button" class="create-room-reset-btn" @click="resetRoomCreateForm">{{ $t('chat.multichat.createRoom.actions.reset') }}</button>
|
||||
</div>
|
||||
<div v-if="roomCreateValidation.range" class="room-create-error room-create-error-block">{{ roomCreateValidation.range }}</div>
|
||||
<div class="room-create-preview">
|
||||
{{ $t('chat.multichat.createRoom.commandPrefix') }}: <code>{{ buildRoomCreateCommandPreview() || '/cr <raumname>' }}</code>
|
||||
</div>
|
||||
<div class="owned-rooms-section">
|
||||
<div class="owned-rooms-title">{{ $t('chat.multichat.createRoom.ownedRooms.title') }}</div>
|
||||
<div class="owned-rooms-hint">{{ $t('chat.multichat.createRoom.ownedRooms.hint') }}</div>
|
||||
<div v-if="!ownRooms.length" class="owned-rooms-empty">
|
||||
{{ $t('chat.multichat.createRoom.ownedRooms.empty') }}
|
||||
</div>
|
||||
<div v-else class="owned-rooms-list">
|
||||
<div v-for="room in ownRooms" :key="`own-room-${room.id}`" class="owned-room-item">
|
||||
<div class="owned-room-main">
|
||||
<span class="owned-room-name">{{ room.title }}</span>
|
||||
<span class="owned-room-badge" :class="room.isPublic ? 'public' : 'private'">
|
||||
{{ room.isPublic ? $t('chat.multichat.createRoom.ownedRooms.public') : $t('chat.multichat.createRoom.ownedRooms.private') }}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" class="owned-room-delete-btn" @click="deleteOwnedRoom(room)">
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
<div class="user-list-header">Teilnehmer ({{ usersInRoom.length }})</div>
|
||||
<div class="user-list-items">
|
||||
@@ -69,6 +172,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showColorPicker && passwordPromptVisible" class="room-password-panel">
|
||||
<div class="room-password-title">{{ $t('chat.multichat.password.title') }}</div>
|
||||
<div class="room-password-message">
|
||||
{{ passwordPromptInvalid ? $t('chat.multichat.password.invalidPrompt', { room: passwordPromptRoom }) : $t('chat.multichat.password.requiredPrompt', { room: passwordPromptRoom }) }}
|
||||
</div>
|
||||
<div class="room-password-controls">
|
||||
<input
|
||||
v-model="passwordPromptValue"
|
||||
class="room-password-input"
|
||||
type="password"
|
||||
:placeholder="$t('chat.multichat.password.inputLabel')"
|
||||
@keyup.enter="submitRoomPassword"
|
||||
/>
|
||||
<button type="button" class="send-btn" @click="submitRoomPassword">{{ $t('chat.multichat.password.submit') }}</button>
|
||||
<button type="button" class="create-room-reset-btn" @click="cancelRoomPassword">{{ $t('chat.multichat.password.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!showColorPicker" class="multi-chat-input">
|
||||
<input v-model="input" @keyup.enter="sendMessage" class="chat-input"
|
||||
:placeholder="$t('chat.multichat.placeholder')" />
|
||||
@@ -116,7 +236,7 @@
|
||||
|
||||
<script>
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import { fetchPublicRooms } from '@/api/chatApi.js';
|
||||
import { fetchPublicRooms, fetchRoomCreateOptions, fetchOwnRooms } from '@/api/chatApi.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getChatWsUrl, getChatWsCandidates, getChatWsProtocols } from '@/services/chatWs.js';
|
||||
|
||||
@@ -130,6 +250,30 @@ export default {
|
||||
try {
|
||||
return !!(this.menu && this.menu.administration);
|
||||
} catch (_) { return false; }
|
||||
},
|
||||
roomCreateValidation() {
|
||||
const errors = {};
|
||||
const name = (this.roomCreateForm.roomName || '').trim();
|
||||
const minAge = this.parseOptionalInteger(this.roomCreateForm.minAge);
|
||||
const maxAge = this.parseOptionalInteger(this.roomCreateForm.maxAge);
|
||||
const rightId = this.parseOptionalInteger(this.roomCreateForm.rightId);
|
||||
const typeId = this.parseOptionalInteger(this.roomCreateForm.typeId);
|
||||
const password = this.roomCreateForm.password || '';
|
||||
|
||||
if (!name) errors.roomName = this.$t('chat.multichat.createRoom.validation.roomNameRequired');
|
||||
if (minAge !== null && minAge < 0) errors.minAge = this.$t('chat.multichat.createRoom.validation.minAgeInvalid');
|
||||
if (maxAge !== null && maxAge < 0) errors.maxAge = this.$t('chat.multichat.createRoom.validation.maxAgeInvalid');
|
||||
if (minAge !== null && maxAge !== null && minAge > maxAge) {
|
||||
errors.range = this.$t('chat.multichat.createRoom.validation.ageRangeInvalid');
|
||||
}
|
||||
if (password.includes(' ')) errors.password = this.$t('chat.multichat.createRoom.validation.passwordSpaces');
|
||||
if (rightId !== null && rightId <= 0) errors.rightId = this.$t('chat.multichat.createRoom.validation.rightIdInvalid');
|
||||
if (typeId !== null && typeId <= 0) errors.typeId = this.$t('chat.multichat.createRoom.validation.typeIdInvalid');
|
||||
|
||||
return errors;
|
||||
},
|
||||
canSendRoomCreate() {
|
||||
return Object.keys(this.roomCreateValidation).length === 0;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -140,6 +284,10 @@ export default {
|
||||
beforeUnmount() {
|
||||
// Safety: ensure connection is shut down on page/navigation leave
|
||||
this.opened = false;
|
||||
if (this.pendingRoomCreateTimer) {
|
||||
clearTimeout(this.pendingRoomCreateTimer);
|
||||
this.pendingRoomCreateTimer = null;
|
||||
}
|
||||
this.disconnectChatSocket();
|
||||
try { window.removeEventListener('online', this.onOnline); } catch (_) { }
|
||||
try { document.removeEventListener('click', this.onGlobalClick); } catch (_) { }
|
||||
@@ -168,10 +316,33 @@ export default {
|
||||
token: null,
|
||||
announcedRoomEnter: false,
|
||||
showColorPicker: false,
|
||||
showRoomCreatePanel: false,
|
||||
selectedColor: '#000000',
|
||||
lastColor: '#000000',
|
||||
hexInput: '#000000',
|
||||
hexInvalid: false,
|
||||
roomCreateForm: {
|
||||
roomName: '',
|
||||
visibility: '',
|
||||
gender: '',
|
||||
minAge: null,
|
||||
maxAge: null,
|
||||
password: '',
|
||||
friendsOnly: false,
|
||||
rightId: null,
|
||||
typeId: null
|
||||
},
|
||||
roomCreateRights: [],
|
||||
roomCreateTypes: [],
|
||||
ownRooms: [],
|
||||
pendingRoomCreateName: '',
|
||||
pendingRoomCreateAttempts: 0,
|
||||
pendingRoomCreateTimer: null,
|
||||
roomPasswords: {},
|
||||
passwordPromptVisible: false,
|
||||
passwordPromptInvalid: false,
|
||||
passwordPromptRoom: '',
|
||||
passwordPromptValue: '',
|
||||
// Palette state
|
||||
paletteWidth: 420,
|
||||
paletteHeight: 220,
|
||||
@@ -201,8 +372,8 @@ export default {
|
||||
happyDelayMs: 40,
|
||||
// Join fallback delay if token is slow to arrive
|
||||
joinFallbackDelayMs: 120,
|
||||
// Limit how many parallel WS candidates to race (prevents server socket buildup)
|
||||
raceLimit: 3
|
||||
// Default: only one connection attempt in parallel (prevents duplicate daemon sockets)
|
||||
raceLimit: 1
|
||||
};
|
||||
},
|
||||
// Hinweis: Öffnen erfolgt über methods.open(), damit Parent per Ref aufrufen kann
|
||||
@@ -216,14 +387,280 @@ export default {
|
||||
selectedRoom(newVal, oldVal) {
|
||||
if (newVal && this.transportConnected) {
|
||||
const room = this.getSelectedRoomName();
|
||||
if (room) this.sendWithToken({ type: 'join', room, name: this.user?.username || '' });
|
||||
if (room) this.sendWithToken({ type: 'join', room, password: this.getRoomPassword(room) });
|
||||
this.messages = [];
|
||||
this.usersInRoom = [];
|
||||
this.selectedTargetUser = null;
|
||||
this.passwordPromptVisible = false;
|
||||
this.passwordPromptInvalid = false;
|
||||
this.passwordPromptRoom = '';
|
||||
this.passwordPromptValue = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDefaultRoomCreateForm() {
|
||||
return {
|
||||
roomName: '',
|
||||
visibility: '',
|
||||
gender: '',
|
||||
minAge: null,
|
||||
maxAge: null,
|
||||
password: '',
|
||||
friendsOnly: false,
|
||||
rightId: null,
|
||||
typeId: null
|
||||
};
|
||||
},
|
||||
toggleRoomCreatePanel() {
|
||||
this.showRoomCreatePanel = !this.showRoomCreatePanel;
|
||||
if (!this.showRoomCreatePanel) return;
|
||||
this.showColorPicker = false;
|
||||
},
|
||||
resetRoomCreateForm() {
|
||||
this.roomCreateForm = this.getDefaultRoomCreateForm();
|
||||
},
|
||||
clearPendingRoomCreateTracking() {
|
||||
if (this.pendingRoomCreateTimer) {
|
||||
clearTimeout(this.pendingRoomCreateTimer);
|
||||
this.pendingRoomCreateTimer = null;
|
||||
}
|
||||
this.pendingRoomCreateName = '';
|
||||
this.pendingRoomCreateAttempts = 0;
|
||||
},
|
||||
roomNamesEqual(a, b) {
|
||||
return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase();
|
||||
},
|
||||
getRoomPassword(roomName) {
|
||||
if (!roomName) return '';
|
||||
return this.roomPasswords[roomName] || '';
|
||||
},
|
||||
handleRoomPasswordError(errorCode) {
|
||||
const room = this.getSelectedRoomName();
|
||||
if (!room || this.passwordPromptVisible) return;
|
||||
this.passwordPromptRoom = room;
|
||||
this.passwordPromptInvalid = errorCode === 'room_password_invalid';
|
||||
this.passwordPromptValue = '';
|
||||
this.passwordPromptVisible = true;
|
||||
},
|
||||
submitRoomPassword() {
|
||||
const room = this.passwordPromptRoom || this.getSelectedRoomName();
|
||||
if (!room) {
|
||||
this.passwordPromptVisible = false;
|
||||
return;
|
||||
}
|
||||
const password = (this.passwordPromptValue || '').trim();
|
||||
if (!password) {
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
user: 'System',
|
||||
text: this.$t('chat.multichat.password.empty')
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.roomPasswords[room] = password;
|
||||
this.passwordPromptVisible = false;
|
||||
this.passwordPromptInvalid = false;
|
||||
this.passwordPromptRoom = '';
|
||||
this.passwordPromptValue = '';
|
||||
this.sendWithToken({ type: 'join', room, password });
|
||||
},
|
||||
cancelRoomPassword() {
|
||||
const room = this.passwordPromptRoom || this.getSelectedRoomName();
|
||||
this.passwordPromptVisible = false;
|
||||
this.passwordPromptInvalid = false;
|
||||
this.passwordPromptRoom = '';
|
||||
this.passwordPromptValue = '';
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
user: 'System',
|
||||
text: this.$t('chat.multichat.password.cancelled', { room })
|
||||
});
|
||||
},
|
||||
tryConfirmRoomCreateSuccess() {
|
||||
if (!this.pendingRoomCreateName) return false;
|
||||
const created = this.ownRooms.find((r) => this.roomNamesEqual(r.title, this.pendingRoomCreateName));
|
||||
if (!created) return false;
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
user: 'System',
|
||||
text: this.$t('chat.multichat.createRoom.messages.created', { room: created.title || this.pendingRoomCreateName })
|
||||
});
|
||||
this.clearPendingRoomCreateTracking();
|
||||
return true;
|
||||
},
|
||||
scheduleRoomCreateConfirmationCheck() {
|
||||
if (!this.pendingRoomCreateName) return;
|
||||
if (this.pendingRoomCreateAttempts >= 6) {
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
user: 'System',
|
||||
text: this.$t('chat.multichat.createRoom.messages.createNotConfirmed', { room: this.pendingRoomCreateName })
|
||||
});
|
||||
this.clearPendingRoomCreateTracking();
|
||||
return;
|
||||
}
|
||||
if (this.pendingRoomCreateTimer) {
|
||||
clearTimeout(this.pendingRoomCreateTimer);
|
||||
this.pendingRoomCreateTimer = null;
|
||||
}
|
||||
this.pendingRoomCreateTimer = setTimeout(async () => {
|
||||
this.pendingRoomCreateAttempts += 1;
|
||||
await this.loadOwnRooms();
|
||||
if (!this.tryConfirmRoomCreateSuccess()) {
|
||||
this.scheduleRoomCreateConfirmationCheck();
|
||||
}
|
||||
}, 1200);
|
||||
},
|
||||
buildRoomCreateCommandPreview() {
|
||||
return this.buildRoomCreateCommand();
|
||||
},
|
||||
getUserRightLabel(right) {
|
||||
const raw = (right?.title || '').trim();
|
||||
if (!raw) return right?.id ?? '';
|
||||
const candidates = [
|
||||
`chat.multichat.createRoom.rights.${raw}`,
|
||||
`navigation.${raw}`,
|
||||
`navigation.m-administration.${raw}`
|
||||
];
|
||||
for (const key of candidates) {
|
||||
if (this.$te(key)) return this.$t(key);
|
||||
}
|
||||
return raw
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
},
|
||||
getRoomTypeLabel(roomType) {
|
||||
const raw = (roomType?.name || '').trim();
|
||||
if (!raw) return roomType?.id ?? '';
|
||||
const key = `chat.multichat.createRoom.types.${raw}`;
|
||||
return this.$te(key) ? this.$t(key) : raw;
|
||||
},
|
||||
async loadRoomCreateOptions() {
|
||||
try {
|
||||
const options = await fetchRoomCreateOptions();
|
||||
this.roomCreateRights = Array.isArray(options?.rights) ? options.rights : [];
|
||||
this.roomCreateTypes = Array.isArray(options?.roomTypes) ? options.roomTypes : [];
|
||||
} catch (e) {
|
||||
console.error('Failed loading room create options', e);
|
||||
this.roomCreateRights = [];
|
||||
this.roomCreateTypes = [];
|
||||
}
|
||||
},
|
||||
async loadOwnRooms() {
|
||||
try {
|
||||
const rooms = await fetchOwnRooms();
|
||||
this.ownRooms = Array.isArray(rooms) ? rooms : [];
|
||||
this.tryConfirmRoomCreateSuccess();
|
||||
} catch (e) {
|
||||
console.error('Failed loading own rooms', e);
|
||||
this.ownRooms = [];
|
||||
}
|
||||
},
|
||||
async deleteOwnedRoom(room) {
|
||||
const title = room?.title || '';
|
||||
const confirmed = window.confirm(this.$t('chat.multichat.createRoom.ownedRooms.confirmDelete', { room: title }));
|
||||
if (!confirmed) return;
|
||||
if (!this.transportConnected) {
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
user: 'System',
|
||||
text: this.$t('chat.multichat.createRoom.messages.noConnection')
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = { type: 'message', message: `/dr ${title}` };
|
||||
if (this.debug) console.log('[Chat WS >>]', payload);
|
||||
this.sendWithToken(payload);
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
user: 'System',
|
||||
text: this.$t('chat.multichat.createRoom.ownedRooms.deleteSent', { room: title })
|
||||
});
|
||||
this.requestRoomRefreshAfterCreate();
|
||||
setTimeout(() => this.loadOwnRooms(), 700);
|
||||
setTimeout(() => this.loadOwnRooms(), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed deleting own room', e);
|
||||
this.messages.push({
|
||||
id: Date.now(),
|
||||
user: 'System',
|
||||
text: this.$t('chat.multichat.createRoom.ownedRooms.deleteError')
|
||||
});
|
||||
}
|
||||
},
|
||||
parseOptionalInteger(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return Math.trunc(num);
|
||||
},
|
||||
buildRoomCreateCommand() {
|
||||
const name = (this.roomCreateForm.roomName || '').trim();
|
||||
if (!name) return '';
|
||||
const parts = ['/cr', name];
|
||||
if (this.roomCreateForm.visibility === 'public') {
|
||||
parts.push('public', 'public=true');
|
||||
} else if (this.roomCreateForm.visibility === 'private') {
|
||||
parts.push('private', 'public=false');
|
||||
}
|
||||
if (this.roomCreateForm.gender) parts.push(`gender=${this.roomCreateForm.gender}`);
|
||||
const minAge = this.parseOptionalInteger(this.roomCreateForm.minAge);
|
||||
if (minAge !== null && minAge >= 0) {
|
||||
parts.push(`min_age=${minAge}`);
|
||||
}
|
||||
const maxAge = this.parseOptionalInteger(this.roomCreateForm.maxAge);
|
||||
if (maxAge !== null && maxAge >= 0) {
|
||||
parts.push(`max_age=${maxAge}`);
|
||||
}
|
||||
const password = (this.roomCreateForm.password || '').trim();
|
||||
if (password) parts.push(`password=${password}`);
|
||||
if (this.roomCreateForm.friendsOnly) parts.push('friends_only=true');
|
||||
const rightId = this.parseOptionalInteger(this.roomCreateForm.rightId);
|
||||
if (rightId !== null && rightId > 0) {
|
||||
parts.push(`right_id=${rightId}`);
|
||||
}
|
||||
const typeId = this.parseOptionalInteger(this.roomCreateForm.typeId);
|
||||
if (typeId !== null && typeId > 0) {
|
||||
parts.push(`type_id=${typeId}`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
},
|
||||
requestRoomRefreshAfterCreate() {
|
||||
const requestRooms = () => {
|
||||
if (this.isAdmin) this.sendWithToken({ type: 'reload_rooms' });
|
||||
this.sendWithToken({ type: 'rooms' });
|
||||
};
|
||||
setTimeout(requestRooms, 500);
|
||||
setTimeout(requestRooms, 1800);
|
||||
},
|
||||
sendCreateRoomCommand() {
|
||||
if (!this.transportConnected) {
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.noConnection') });
|
||||
return;
|
||||
}
|
||||
if (!this.canSendRoomCreate) {
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.invalidForm') });
|
||||
return;
|
||||
}
|
||||
const command = this.buildRoomCreateCommand();
|
||||
if (!command) {
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.roomNameMissing') });
|
||||
return;
|
||||
}
|
||||
const payload = { type: 'message', message: command };
|
||||
if (this.debug) console.log('[Chat WS >>]', payload);
|
||||
this.sendWithToken(payload);
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.sent', { command }) });
|
||||
this.clearPendingRoomCreateTracking();
|
||||
this.pendingRoomCreateName = (this.roomCreateForm.roomName || '').trim();
|
||||
this.pendingRoomCreateAttempts = 0;
|
||||
this.requestRoomRefreshAfterCreate();
|
||||
this.scheduleRoomCreateConfirmationCheck();
|
||||
},
|
||||
selectTargetUser(name) {
|
||||
if (this.selectedTargetUser === name) {
|
||||
this.selectedTargetUser = null; // toggle off
|
||||
@@ -260,6 +697,7 @@ export default {
|
||||
onDialogClose() {
|
||||
// Mark as closed first so any async close events won't schedule reconnect
|
||||
this.opened = false;
|
||||
this.clearPendingRoomCreateTracking();
|
||||
console.log('[Chat WS] dialog close — closing websocket');
|
||||
this.disconnectChatSocket();
|
||||
// Remove network event listeners
|
||||
@@ -301,14 +739,9 @@ export default {
|
||||
async reloadRoomsAdmin() {
|
||||
if (!this.isAdmin) return;
|
||||
try {
|
||||
const current = this.selectedRoom;
|
||||
const data = await fetchPublicRooms();
|
||||
const rooms = Array.isArray(data) ? data : [];
|
||||
this.rooms = rooms;
|
||||
if (!rooms.find(r => r.id === current)) {
|
||||
this.selectedRoom = rooms.length ? rooms[0].id : null;
|
||||
}
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: 'Raumliste aktualisiert.' });
|
||||
this.sendWithToken({ type: 'reload_rooms' });
|
||||
this.sendWithToken({ type: 'rooms' });
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: 'Raum-Reload angefordert.' });
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Neuladen der Räume', e);
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: 'Fehler beim Neuladen der Raumliste.' });
|
||||
@@ -324,6 +757,9 @@ export default {
|
||||
this.selectedTargetUser = null;
|
||||
this.input = '';
|
||||
this.showOptions = false;
|
||||
this.showRoomCreatePanel = false;
|
||||
this.loadRoomCreateOptions();
|
||||
this.loadOwnRooms();
|
||||
this.announcedRoomEnter = false;
|
||||
this.$refs.dialog.open();
|
||||
// Stelle die WS-Verbindung her, wenn der Dialog geöffnet wird
|
||||
@@ -394,12 +830,7 @@ export default {
|
||||
});
|
||||
},
|
||||
getRaceLimit() {
|
||||
try {
|
||||
const ls = localStorage.getItem('chatWsRaceMax');
|
||||
const n = parseInt(ls, 10);
|
||||
if (!isNaN(n) && n > 0) return Math.min(n, 6);
|
||||
} catch (_) { }
|
||||
return this.raceLimit || 3;
|
||||
return 1;
|
||||
},
|
||||
spawnCandidate(url, protocols) {
|
||||
if (!this.opened) return;
|
||||
@@ -446,7 +877,8 @@ export default {
|
||||
// Drop references to losers so GC can collect
|
||||
this.pendingWs = [];
|
||||
// Prepare handshake like before
|
||||
const init = { type: 'init', name: this.user?.username || '', room: this.getSelectedRoomName() || '' };
|
||||
const initRoom = this.getSelectedRoomName() || '';
|
||||
const init = { type: 'init', name: this.user?.username || '', room: initRoom, password: this.getRoomPassword(initRoom) };
|
||||
if (this.debug) console.log('[Chat WS >>]', init);
|
||||
this.wsSend(init);
|
||||
if (this.connectAttemptTimeout) clearTimeout(this.connectAttemptTimeout);
|
||||
@@ -543,7 +975,8 @@ export default {
|
||||
this.transportConnected = true;
|
||||
const dt = Date.now() - (this.wsStartAt || Date.now());
|
||||
console.log('[Chat WS] open in', dt, 'ms', '| protocol:', ws.protocol || '(none)', '| url:', url);
|
||||
const init = { type: 'init', name: this.user?.username || '', room: this.getSelectedRoomName() || '' };
|
||||
const initRoom = this.getSelectedRoomName() || '';
|
||||
const init = { type: 'init', name: this.user?.username || '', room: initRoom, password: this.getRoomPassword(initRoom) };
|
||||
if (this.debug) console.log('[Chat WS >>]', init);
|
||||
this.wsSend(init);
|
||||
if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; }
|
||||
@@ -661,9 +1094,8 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
// Send a ping message to keep connection alive
|
||||
this.wsSend({ type: 'ping' });
|
||||
console.log('[Chat WS] Heartbeat sent');
|
||||
// Keepalive via supported protocol command
|
||||
if (this.token) this.wsSend({ type: 'userlist', token: this.token });
|
||||
} catch (error) {
|
||||
console.warn('[Chat WS] Heartbeat failed:', error);
|
||||
this.stopHeartbeat();
|
||||
@@ -823,7 +1255,7 @@ export default {
|
||||
action() {
|
||||
if (!this.input.trim()) return;
|
||||
if (!this.selectedTargetUser) return; // Nur mit Auswahl
|
||||
const payload = { type: 'do', value: this.input, to: this.selectedTargetUser };
|
||||
const payload = { type: 'do', message: `${this.input} ${this.selectedTargetUser}`.trim() };
|
||||
if (this.debug) console.log('[Chat WS >>]', payload);
|
||||
this.sendWithToken(payload);
|
||||
this.input = '';
|
||||
@@ -1053,6 +1485,16 @@ export default {
|
||||
},
|
||||
onWsObject(obj) {
|
||||
if (!obj) return;
|
||||
if (obj.type === 'error') {
|
||||
const text = obj.message || 'chat_error';
|
||||
if (text === 'room_password_required' || text === 'room_password_invalid') {
|
||||
this.handleRoomPasswordError(text);
|
||||
return;
|
||||
}
|
||||
this.setStatus('error');
|
||||
this.messages.push({ id: Date.now(), user: 'System', text: `Fehler: ${text}` });
|
||||
return;
|
||||
}
|
||||
// Token handshake: only when explicitly marked as token-type
|
||||
if (obj.type === 'token' || obj.type === 1) {
|
||||
let tok = obj.token;
|
||||
@@ -1062,6 +1504,8 @@ export default {
|
||||
}
|
||||
if (tok) {
|
||||
this.token = tok;
|
||||
this.sendWithToken({ type: 'rooms' });
|
||||
this.sendWithToken({ type: 'userlist' });
|
||||
// No extra join here; we already sent init with room
|
||||
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
|
||||
this.flushPending();
|
||||
@@ -1136,11 +1580,33 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (obj.type === 3 && Array.isArray(obj.message)) {
|
||||
const names = obj.message.map(r => r.name).filter(Boolean).join(', ');
|
||||
this.handleIncoming({ type: 'system', text: names ? `Rooms: ${names}` : 'Rooms updated' });
|
||||
const mapped = obj.message
|
||||
.filter(r => r && r.name)
|
||||
.map((r, index) => ({
|
||||
id: r.name || index + 1,
|
||||
title: r.name,
|
||||
name: r.name,
|
||||
users: Number(r.users || 0)
|
||||
}));
|
||||
if (mapped.length > 0) {
|
||||
const currentName = this.getSelectedRoomName();
|
||||
this.rooms = mapped;
|
||||
if (currentName) {
|
||||
const existing = mapped.find(r => r.name === currentName);
|
||||
this.selectedRoom = existing ? existing.id : mapped[0].id;
|
||||
} else if (!this.selectedRoom) {
|
||||
this.selectedRoom = mapped[0].id;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (obj.type === 5) {
|
||||
if (obj.message === 'room_entered') {
|
||||
const to = obj.to || obj.room || this.getSelectedRoomName();
|
||||
this.handleIncoming({ type: 'system', code: 'room_entered', tr: 'room_entered', to });
|
||||
this.sendWithToken({ type: 'userlist' });
|
||||
return;
|
||||
}
|
||||
const msg = obj.message;
|
||||
if (typeof msg === 'string') {
|
||||
// Some servers send a JSON-encoded string here; parse if it looks like JSON
|
||||
@@ -1457,10 +1923,24 @@ export default {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.room-left-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.room-select {
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.create-room-toggle-btn {
|
||||
border: 1px solid #bbb;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
padding: 0.3em 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1567,6 +2047,188 @@ export default {
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
.room-create-panel {
|
||||
border: 1px solid #222;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
padding: 0.7em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.room-create-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
.room-create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6em;
|
||||
}
|
||||
|
||||
.room-create-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25em;
|
||||
}
|
||||
|
||||
.room-create-grid input,
|
||||
.room-create-grid select {
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
padding: 0.35em 0.5em;
|
||||
}
|
||||
|
||||
.invalid-input {
|
||||
border-color: #c62828 !important;
|
||||
background: #fff6f6;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.4em !important;
|
||||
}
|
||||
|
||||
.room-create-actions {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
margin-top: 0.8em;
|
||||
}
|
||||
|
||||
.create-room-reset-btn {
|
||||
border: 1px solid #bbb;
|
||||
background: #eee;
|
||||
border-radius: 3px;
|
||||
padding: 0.3em 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.room-create-preview {
|
||||
margin-top: 0.7em;
|
||||
font-size: 0.9em;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.owned-rooms-section {
|
||||
margin-top: 0.9em;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 0.7em;
|
||||
}
|
||||
|
||||
.owned-rooms-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.owned-rooms-hint {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
margin-bottom: 0.45em;
|
||||
}
|
||||
|
||||
.owned-rooms-empty {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.owned-rooms-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35em;
|
||||
}
|
||||
|
||||
.owned-room-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6em;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
padding: 0.35em 0.5em;
|
||||
}
|
||||
|
||||
.owned-room-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.owned-room-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.owned-room-badge {
|
||||
font-size: 0.75em;
|
||||
border-radius: 999px;
|
||||
padding: 0.1em 0.5em;
|
||||
border: 1px solid #bbb;
|
||||
}
|
||||
|
||||
.owned-room-badge.public {
|
||||
background: #e8f5e9;
|
||||
border-color: #81c784;
|
||||
}
|
||||
|
||||
.owned-room-badge.private {
|
||||
background: #fff3e0;
|
||||
border-color: #ffb74d;
|
||||
}
|
||||
|
||||
.owned-room-delete-btn {
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
padding: 0.2em 0.55em;
|
||||
}
|
||||
|
||||
.room-create-error {
|
||||
color: #b00020;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.room-create-error-block {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
.room-password-panel {
|
||||
margin-top: 0.6em;
|
||||
margin-bottom: 0.6em;
|
||||
padding: 0.6em;
|
||||
border: 1px solid #d7d7d7;
|
||||
border-radius: 6px;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.room-password-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.room-password-message {
|
||||
font-size: 0.9em;
|
||||
color: #444;
|
||||
margin-bottom: 0.45em;
|
||||
}
|
||||
|
||||
.room-password-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45em;
|
||||
}
|
||||
|
||||
.room-password-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 3px;
|
||||
padding: 0.35em 0.5em;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{{ $t('falukant.branch.selection.selected') }}:
|
||||
<strong>{{ selectedRegion.name }}</strong>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<label class="form-label form-field">
|
||||
{{ $t('falukant.branch.columns.type') }}
|
||||
<select v-model="selectedType" class="form-control">
|
||||
<option
|
||||
@@ -72,8 +72,10 @@
|
||||
({{ formatCost(computeBranchCost(type)) }})
|
||||
</option>
|
||||
</select>
|
||||
<span class="form-hint">Waehle zuerst Region und dann den Niederlassungstyp.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="form-hint">Waehle auf der Karte eine freie Region aus.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +85,7 @@
|
||||
<script>
|
||||
import DialogWidget from '@/components/DialogWidget.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'CreateBranchDialog',
|
||||
@@ -109,7 +112,7 @@
|
||||
dialogButtons() {
|
||||
return [
|
||||
{ text: this.$t('Cancel'), action: this.close },
|
||||
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm },
|
||||
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm, disabled: !this.selectedRegion || !this.selectedType },
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -144,7 +147,10 @@
|
||||
},
|
||||
|
||||
async onConfirm() {
|
||||
if (!this.selectedRegion || !this.selectedType) return;
|
||||
if (!this.selectedRegion || !this.selectedType) {
|
||||
showError(this, 'Bitte zuerst Region und Typ auswaehlen.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/falukant/branches', {
|
||||
@@ -152,13 +158,14 @@
|
||||
branchTypeId: this.selectedType,
|
||||
});
|
||||
this.$emit('create-branch');
|
||||
showSuccess(this, 'Niederlassung erfolgreich erstellt.');
|
||||
this.close();
|
||||
} catch (e) {
|
||||
if (e?.response?.status === 412 && e?.response?.data?.error === 'insufficientFunds') {
|
||||
alert(this.$t('falukant.branch.actions.insufficientFunds'));
|
||||
showError(this, this.$t('falukant.branch.actions.insufficientFunds'));
|
||||
} else {
|
||||
console.error('Error creating branch', e);
|
||||
alert(this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
|
||||
showApiError(this, e, this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="25em"
|
||||
height="15em" name="ErrorDialog" :isTitleTranslated=true>
|
||||
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="28em"
|
||||
height="16em" name="ErrorDialog" :isTitleTranslated=true>
|
||||
<div class="error-content">
|
||||
<span class="error-content__badge">Fehler</span>
|
||||
<p>{{ translatedErrorMessage }}</p>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
@@ -45,8 +46,27 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.error-content {
|
||||
padding: 1em;
|
||||
color: red;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-content__badge {
|
||||
justify-self: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(177, 59, 53, 0.12);
|
||||
color: var(--color-danger);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.error-content p {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="25em"
|
||||
height="15em" name="MessageDialog" :isTitleTranslated=false>
|
||||
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="28em"
|
||||
height="16em" name="MessageDialog" :isTitleTranslated=false>
|
||||
<div class="message-content">
|
||||
<span class="message-content__badge">Hinweis</span>
|
||||
<p>{{ translatedMessage }}</p>
|
||||
</div>
|
||||
</DialogWidget>
|
||||
@@ -41,14 +42,6 @@ export default {
|
||||
if (this.message.startsWith('tr:')) {
|
||||
const i18nKey = this.message.substring(3);
|
||||
const translation = this.$t(i18nKey);
|
||||
console.log('translatedMessage:', {
|
||||
i18nKey: i18nKey,
|
||||
translation: translation,
|
||||
parameters: this.parameters,
|
||||
allMinigames: this.$t('minigames'),
|
||||
crashSection: this.$t('minigames.taxi.crash')
|
||||
});
|
||||
// Ersetze Parameter in der Übersetzung
|
||||
return this.interpolateParameters(translation);
|
||||
}
|
||||
return this.message;
|
||||
@@ -89,26 +82,16 @@ export default {
|
||||
}
|
||||
},
|
||||
interpolateParameters(text) {
|
||||
// Ersetze {key} Platzhalter mit den entsprechenden Werten
|
||||
let result = text;
|
||||
console.log('interpolateParameters:', {
|
||||
originalText: text,
|
||||
parameters: this.parameters
|
||||
});
|
||||
|
||||
for (const [key, value] of Object.entries(this.parameters)) {
|
||||
const placeholder = `{${key}}`;
|
||||
const regex = new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g');
|
||||
result = result.replace(regex, value);
|
||||
console.log(`Replaced ${placeholder} with ${value}:`, result);
|
||||
}
|
||||
|
||||
console.log('Final result:', result);
|
||||
return result;
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
// Stelle sicher, dass Event Listener entfernt wird
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
};
|
||||
@@ -116,8 +99,27 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.message-content {
|
||||
padding: 1em;
|
||||
color: #000000;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message-content__badge {
|
||||
justify-self: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.16);
|
||||
color: #24523a;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message-content p {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,92 @@
|
||||
"connected": "Verbunden",
|
||||
"disconnected": "Getrennt",
|
||||
"error": "Fehler bei der Verbindung"
|
||||
},
|
||||
"reloadRooms": "Räume neu laden",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Chat anzeigen",
|
||||
"toggleCreateRoom": "Raum anlegen",
|
||||
"title": "Neuen Raum erstellen",
|
||||
"commandPrefix": "Kommando",
|
||||
"labels": {
|
||||
"roomName": "Raumname",
|
||||
"visibility": "Sichtbarkeit",
|
||||
"gender": "Geschlecht",
|
||||
"minAge": "Mindestalter",
|
||||
"maxAge": "Höchstalter",
|
||||
"password": "Passwort",
|
||||
"rightId": "Benötigtes Recht",
|
||||
"typeId": "Raumtyp",
|
||||
"friendsOnly": "friends_only=true"
|
||||
},
|
||||
"placeholders": {
|
||||
"roomName": "z. B. Lounge",
|
||||
"password": "ohne Leerzeichen"
|
||||
},
|
||||
"options": {
|
||||
"none": "(keine)",
|
||||
"visibilityPublic": "Öffentlich",
|
||||
"visibilityPrivate": "Privat",
|
||||
"genderMale": "Männlich",
|
||||
"genderFemale": "Weiblich",
|
||||
"genderAny": "Alle / Keine Einschränkung"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Raum erstellen",
|
||||
"reset": "Zurücksetzen"
|
||||
},
|
||||
"validation": {
|
||||
"roomNameRequired": "Raumname ist erforderlich.",
|
||||
"minAgeInvalid": "min_age muss >= 0 sein.",
|
||||
"maxAgeInvalid": "max_age muss >= 0 sein.",
|
||||
"ageRangeInvalid": "min_age darf nicht größer als max_age sein.",
|
||||
"passwordSpaces": "Passwort darf keine Leerzeichen enthalten.",
|
||||
"rightIdInvalid": "right_id muss > 0 sein.",
|
||||
"typeIdInvalid": "type_id muss > 0 sein."
|
||||
},
|
||||
"messages": {
|
||||
"noConnection": "Keine Verbindung zum Chat-Server.",
|
||||
"invalidForm": "Bitte Eingaben im Raum-Formular korrigieren.",
|
||||
"roomNameMissing": "Bitte einen Raumnamen angeben.",
|
||||
"sent": "Raum-Erstellung gesendet: {command}",
|
||||
"created": "Raum \"{room}\" wurde erfolgreich erstellt.",
|
||||
"createNotConfirmed": "Raum \"{room}\" wurde noch nicht bestätigt. Bitte Raumliste prüfen."
|
||||
},
|
||||
"ownedRooms": {
|
||||
"title": "Meine erstellten Räume",
|
||||
"hint": "Löschen per Daemon-Befehl: /dr <raumname> (Alias: /delete_room <raumname>)",
|
||||
"empty": "Du hast noch keine eigenen Räume.",
|
||||
"public": "public",
|
||||
"private": "private",
|
||||
"confirmDelete": "Soll der Raum \"{room}\" wirklich gelöscht werden?",
|
||||
"deleteSent": "Löschbefehl gesendet: /dr {room}",
|
||||
"deleteError": "Raum konnte nicht gelöscht werden."
|
||||
},
|
||||
"rights": {
|
||||
"mainadmin": "Hauptadministrator",
|
||||
"contactrequests": "Kontaktanfragen",
|
||||
"users": "Benutzer",
|
||||
"userrights": "Benutzerrechte",
|
||||
"forum": "Forum",
|
||||
"interests": "Interessen",
|
||||
"falukant": "Falukant",
|
||||
"minigames": "Minispiele",
|
||||
"match3": "Match3",
|
||||
"taxiTools": "Taxi-Tools",
|
||||
"chatrooms": "Chaträume",
|
||||
"servicesStatus": "Service-Status"
|
||||
},
|
||||
"types": {}
|
||||
},
|
||||
"password": {
|
||||
"title": "Passwort erforderlich",
|
||||
"inputLabel": "Passwort eingeben",
|
||||
"submit": "Beitreten",
|
||||
"cancel": "Abbrechen",
|
||||
"requiredPrompt": "Der Raum \"{room}\" ist passwortgeschützt. Bitte Passwort eingeben:",
|
||||
"invalidPrompt": "Falsches Passwort für \"{room}\". Bitte erneut eingeben:",
|
||||
"cancelled": "Beitritt zu \"{room}\" abgebrochen.",
|
||||
"empty": "Passwort darf nicht leer sein."
|
||||
}
|
||||
},
|
||||
"randomchat": {
|
||||
|
||||
@@ -147,6 +147,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"genderAge": {
|
||||
"ageGroups": "infant:2|toddler:4|child:12|teen:18|youngAdult:25|adult:50|mature:70|elder:999",
|
||||
"neutral": {
|
||||
"child": "Kind"
|
||||
},
|
||||
"male": {
|
||||
"infant": "Säugling",
|
||||
"toddler": "Bübchen",
|
||||
"child": "Knabe",
|
||||
"teen": "Jüngling",
|
||||
"youngAdult": "Junker",
|
||||
"adult": "Mann",
|
||||
"mature": "Herr",
|
||||
"elder": "Greis"
|
||||
},
|
||||
"female": {
|
||||
"infant": "Säugling",
|
||||
"toddler": "Mädel",
|
||||
"child": "Göre",
|
||||
"teen": "Dirn",
|
||||
"youngAdult": "Jungfrau",
|
||||
"adult": "Frau",
|
||||
"mature": "Dame",
|
||||
"elder": "Greisin"
|
||||
}
|
||||
},
|
||||
"titles": {
|
||||
"male": {
|
||||
"noncivil": "Leibeigener",
|
||||
@@ -471,6 +497,7 @@
|
||||
"gifts": "Werbegeschenke",
|
||||
"sendGift": "Werbegeschenk senden",
|
||||
"cancel": "Werbung abbrechen",
|
||||
"cancelConfirm": "Willst du die Werbung wirklich abbrechen? Der Fortschritt geht dabei verloren.",
|
||||
"cancelSuccess": "Die Werbung wurde abgebrochen.",
|
||||
"cancelError": "Die Werbung konnte nicht abgebrochen werden.",
|
||||
"cancelTooSoon": "Du kannst die Werbung erst nach 24 Stunden abbrechen.",
|
||||
|
||||
@@ -38,6 +38,92 @@
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"error": "Connection error"
|
||||
},
|
||||
"reloadRooms": "Reload rooms",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Show chat",
|
||||
"toggleCreateRoom": "Create room",
|
||||
"title": "Create new room",
|
||||
"commandPrefix": "Command",
|
||||
"labels": {
|
||||
"roomName": "Room name",
|
||||
"visibility": "Visibility",
|
||||
"gender": "Gender",
|
||||
"minAge": "Minimum age",
|
||||
"maxAge": "Maximum age",
|
||||
"password": "Password",
|
||||
"rightId": "Required right",
|
||||
"typeId": "Room type",
|
||||
"friendsOnly": "friends_only=true"
|
||||
},
|
||||
"placeholders": {
|
||||
"roomName": "e.g. Lounge",
|
||||
"password": "without spaces"
|
||||
},
|
||||
"options": {
|
||||
"none": "(none)",
|
||||
"visibilityPublic": "Public",
|
||||
"visibilityPrivate": "Private",
|
||||
"genderMale": "Male",
|
||||
"genderFemale": "Female",
|
||||
"genderAny": "Any / No restriction"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create room",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"validation": {
|
||||
"roomNameRequired": "Room name is required.",
|
||||
"minAgeInvalid": "min_age must be >= 0.",
|
||||
"maxAgeInvalid": "max_age must be >= 0.",
|
||||
"ageRangeInvalid": "min_age must not be greater than max_age.",
|
||||
"passwordSpaces": "Password must not contain spaces.",
|
||||
"rightIdInvalid": "right_id must be > 0.",
|
||||
"typeIdInvalid": "type_id must be > 0."
|
||||
},
|
||||
"messages": {
|
||||
"noConnection": "No connection to chat server.",
|
||||
"invalidForm": "Please correct the room form inputs.",
|
||||
"roomNameMissing": "Please enter a room name.",
|
||||
"sent": "Room creation sent: {command}",
|
||||
"created": "Room \"{room}\" was created successfully.",
|
||||
"createNotConfirmed": "Room \"{room}\" is not confirmed yet. Please check room list."
|
||||
},
|
||||
"ownedRooms": {
|
||||
"title": "My created rooms",
|
||||
"hint": "Delete via daemon command: /dr <roomname> (alias: /delete_room <roomname>)",
|
||||
"empty": "You have no own rooms yet.",
|
||||
"public": "public",
|
||||
"private": "private",
|
||||
"confirmDelete": "Do you really want to delete room \"{room}\"?",
|
||||
"deleteSent": "Delete command sent: /dr {room}",
|
||||
"deleteError": "Could not delete room."
|
||||
},
|
||||
"rights": {
|
||||
"mainadmin": "Main administrator",
|
||||
"contactrequests": "Contact requests",
|
||||
"users": "Users",
|
||||
"userrights": "User rights",
|
||||
"forum": "Forum",
|
||||
"interests": "Interests",
|
||||
"falukant": "Falukant",
|
||||
"minigames": "Mini games",
|
||||
"match3": "Match3",
|
||||
"taxiTools": "Taxi tools",
|
||||
"chatrooms": "Chat rooms",
|
||||
"servicesStatus": "Service status"
|
||||
},
|
||||
"types": {}
|
||||
},
|
||||
"password": {
|
||||
"title": "Password required",
|
||||
"inputLabel": "Enter password",
|
||||
"submit": "Join room",
|
||||
"cancel": "Cancel",
|
||||
"requiredPrompt": "Room \"{room}\" is password-protected. Please enter password:",
|
||||
"invalidPrompt": "Wrong password for \"{room}\". Please try again:",
|
||||
"cancelled": "Join to \"{room}\" was cancelled.",
|
||||
"empty": "Password must not be empty."
|
||||
}
|
||||
},
|
||||
"randomchat": {
|
||||
|
||||
@@ -451,6 +451,7 @@
|
||||
"spouse": {
|
||||
"wooing": {
|
||||
"cancel": "Cancel wooing",
|
||||
"cancelConfirm": "Do you really want to cancel wooing? Progress will be lost.",
|
||||
"cancelSuccess": "Wooing has been cancelled.",
|
||||
"cancelError": "Wooing could not be cancelled.",
|
||||
"cancelTooSoon": "You can only cancel wooing after 24 hours."
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"admin": {
|
||||
"interests": {
|
||||
"title": "[Admin] - Interessen verwalten",
|
||||
"title": "[Admin] - Administrar intereses",
|
||||
"newinterests": {
|
||||
"name": "Name des Interesses",
|
||||
"status": "Freigegeben",
|
||||
"adultonly": "Nur für Erwachsene",
|
||||
"translations": "Übersetzungen",
|
||||
"isactive": "Aktiviert",
|
||||
"isadult": "Nur für Erwachsene",
|
||||
"delete": "Löschen"
|
||||
"name": "Nombre del interés",
|
||||
"status": "Aprobado",
|
||||
"adultonly": "Solo para adultos",
|
||||
"translations": "Traducciones",
|
||||
"isactive": "Activado",
|
||||
"isadult": "Solo para adultos",
|
||||
"delete": "Eliminar"
|
||||
}
|
||||
},
|
||||
"contacts": {
|
||||
"title": "[Admin] - Kontaktanfragen",
|
||||
"date": "Datum",
|
||||
"from": "Absender",
|
||||
"actions": "Aktionen",
|
||||
"open": "Bearbeiten",
|
||||
"finished": "Abschließen"
|
||||
"title": "[Admin] - Solicitudes de contacto",
|
||||
"date": "Fecha",
|
||||
"from": "Remitente",
|
||||
"actions": "Acciones",
|
||||
"open": "Editar",
|
||||
"finished": "Finalizar"
|
||||
},
|
||||
"editcontactrequest": {
|
||||
"title": "[Admin] - Kontaktanfrage bearbeiten"
|
||||
"title": "[Admin] - Editar solicitud de contacto"
|
||||
},
|
||||
"user": {
|
||||
"name": "Benutzername",
|
||||
"active": "Aktiv",
|
||||
"blocked": "Gesperrt",
|
||||
"actions": "Aktionen",
|
||||
"search": "Suchen"
|
||||
"name": "Nombre de usuario",
|
||||
"active": "Activo",
|
||||
"blocked": "Bloqueado",
|
||||
"actions": "Acciones",
|
||||
"search": "Buscar"
|
||||
},
|
||||
"rights": {
|
||||
"add": "Recht hinzufügen",
|
||||
"select": "Bitte wählen",
|
||||
"current": "Aktuelle Rechte"
|
||||
"add": "Añadir permiso",
|
||||
"select": "Por favor, selecciona",
|
||||
"current": "Permisos actuales"
|
||||
},
|
||||
"forum": {
|
||||
"title": "[Admin] - Forum",
|
||||
"currentForums": "Existierende Foren",
|
||||
"edit": "Ändern",
|
||||
"delete": "Löschen",
|
||||
"createForum": "Anlegen",
|
||||
"currentForums": "Foros existentes",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"createForum": "Crear",
|
||||
"forumName": "Titel",
|
||||
"create": "Anlegen",
|
||||
"create": "Crear",
|
||||
"permissions": {
|
||||
"label": "Berechtigungen",
|
||||
"all": "Jeder",
|
||||
@@ -51,267 +51,267 @@
|
||||
"user": "Nur bestimmte Benutzer",
|
||||
"age": "Nur ab Alter 14"
|
||||
},
|
||||
"selectPermissions": "Bitte auswählen",
|
||||
"confirmDeleteMessage": "Soll das Forum wirklich gelöscht werden?",
|
||||
"confirmDeleteTitle": "Forum löschen"
|
||||
"selectPermissions": "Por favor, selecciona",
|
||||
"confirmDeleteMessage": "¿De verdad quieres eliminar el foro?",
|
||||
"confirmDeleteTitle": "Eliminar foro"
|
||||
},
|
||||
"falukant": {
|
||||
"edituser": {
|
||||
"title": "Falukant Benutzer bearbeiten",
|
||||
"username": "Benutzername",
|
||||
"characterName": "Charaktername",
|
||||
"user": "Benutzer",
|
||||
"success": "Die Änderungen wurden gespeichert.",
|
||||
"error": "Die Änderungen konnten nicht gespeichert werden.",
|
||||
"errorLoadingBranches": "Fehler beim Laden der Niederlassungen.",
|
||||
"errorUpdatingStock": "Fehler beim Aktualisieren des Lagers.",
|
||||
"stockUpdated": "Lager erfolgreich aktualisiert.",
|
||||
"search": "Suchen",
|
||||
"title": "Editar usuario de Falukant",
|
||||
"username": "Nombre de usuario",
|
||||
"characterName": "Nombre del personaje",
|
||||
"user": "Usuario",
|
||||
"success": "Los cambios se han guardado.",
|
||||
"error": "No se pudieron guardar los cambios.",
|
||||
"errorLoadingBranches": "Error al cargar las sucursales.",
|
||||
"errorUpdatingStock": "Error al actualizar el almacén.",
|
||||
"stockUpdated": "Almacén actualizado correctamente.",
|
||||
"search": "Buscar",
|
||||
"tabs": {
|
||||
"userdata": "Benutzerdaten",
|
||||
"branches": "Niederlassungen"
|
||||
"userdata": "Datos del usuario",
|
||||
"branches": "Sucursales"
|
||||
},
|
||||
"branches": {
|
||||
"title": "Niederlassungen & Lager",
|
||||
"noStocks": "Kein Lager vorhanden",
|
||||
"noBranches": "Keine Niederlassungen gefunden",
|
||||
"addStock": "Lager hinzufügen",
|
||||
"stockType": "Lagertyp",
|
||||
"selectStockType": "Lagertyp auswählen",
|
||||
"quantity": "Menge",
|
||||
"allStocksAdded": "Alle verfügbaren Lagertypen sind bereits vorhanden"
|
||||
"title": "Sucursales y almacén",
|
||||
"noStocks": "No hay almacén",
|
||||
"noBranches": "No se han encontrado sucursales",
|
||||
"addStock": "Añadir almacén",
|
||||
"stockType": "Tipo de almacén",
|
||||
"selectStockType": "Seleccionar tipo de almacén",
|
||||
"quantity": "Cantidad",
|
||||
"allStocksAdded": "Todos los tipos de almacén disponibles ya existen"
|
||||
},
|
||||
"errorLoadingStockTypes": "Fehler beim Laden der Lagertypen.",
|
||||
"errorAddingStock": "Fehler beim Hinzufügen des Lagers.",
|
||||
"stockAdded": "Lager erfolgreich hinzugefügt.",
|
||||
"invalidStockData": "Bitte gültige Lagertyp- und Mengenangaben eingeben."
|
||||
"errorLoadingStockTypes": "Error al cargar los tipos de almacén.",
|
||||
"errorAddingStock": "Error al añadir el almacén.",
|
||||
"stockAdded": "Almacén añadido correctamente.",
|
||||
"invalidStockData": "Por favor, introduce un tipo de almacén y una cantidad válidos."
|
||||
},
|
||||
"map": {
|
||||
"title": "Falukant Karten-Editor (Regionen)",
|
||||
"description": "Zeichne Rechtecke auf der Falukant-Karte und weise sie Städten zu.",
|
||||
"title": "Editor de mapas de Falukant (regiones)",
|
||||
"description": "Dibuja rectángulos en el mapa de Falukant y asígnalos a ciudades.",
|
||||
"tabs": {
|
||||
"regions": "Positionen",
|
||||
"distances": "Entfernungen"
|
||||
"regions": "Posiciones",
|
||||
"distances": "Distancias"
|
||||
},
|
||||
"regionList": "Städte",
|
||||
"noCoords": "Keine Koordinaten gesetzt",
|
||||
"currentRect": "Aktuelles Rechteck",
|
||||
"hintDraw": "Wähle eine Stadt und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.",
|
||||
"saveAll": "Alle geänderten Städte speichern",
|
||||
"connectionsTitle": "Verbindungen (region_distance)",
|
||||
"source": "Von",
|
||||
"target": "Nach",
|
||||
"selectSource": "Quellstadt wählen",
|
||||
"selectTarget": "Zielstadt wählen",
|
||||
"regionList": "Ciudades",
|
||||
"noCoords": "No se han establecido coordenadas",
|
||||
"currentRect": "Rectángulo actual",
|
||||
"hintDraw": "Elige una ciudad y dibuja un rectángulo con el ratón sobre el mapa para definir la posición.",
|
||||
"saveAll": "Guardar todas las ciudades modificadas",
|
||||
"connectionsTitle": "Conexiones (region_distance)",
|
||||
"source": "Desde",
|
||||
"target": "Hacia",
|
||||
"selectSource": "Elegir ciudad origen",
|
||||
"selectTarget": "Elegir ciudad destino",
|
||||
"mode": "Transportart",
|
||||
"modeLand": "Land",
|
||||
"modeWater": "Wasser",
|
||||
"modeAir": "Luft",
|
||||
"distance": "Entfernung",
|
||||
"saveConnection": "Verbindung speichern",
|
||||
"pickOnMap": "Auf Karte wählen",
|
||||
"errorSaveConnection": "Die Verbindung konnte nicht gespeichert werden.",
|
||||
"errorDeleteConnection": "Die Verbindung konnte nicht gelöscht werden.",
|
||||
"confirmDeleteConnection": "Verbindung wirklich löschen?"
|
||||
"distance": "Distancia",
|
||||
"saveConnection": "Guardar conexión",
|
||||
"pickOnMap": "Seleccionar en el mapa",
|
||||
"errorSaveConnection": "No se pudo guardar la conexión.",
|
||||
"errorDeleteConnection": "No se pudo eliminar la conexión.",
|
||||
"confirmDeleteConnection": "¿Eliminar la conexión?"
|
||||
},
|
||||
"createNPC": {
|
||||
"title": "NPCs erstellen",
|
||||
"region": "Stadt",
|
||||
"allRegions": "Alle Städte",
|
||||
"ageRange": "Altersbereich",
|
||||
"to": "bis",
|
||||
"years": "Jahre",
|
||||
"titleRange": "Titel-Bereich",
|
||||
"count": "Anzahl pro Stadt-Titel-Kombination",
|
||||
"countHelp": "Diese Anzahl wird für jede Kombination aus gewählter Stadt und Titel erstellt.",
|
||||
"create": "NPCs erstellen",
|
||||
"creating": "Erstelle...",
|
||||
"result": "Ergebnis",
|
||||
"createdCount": "{count} NPCs wurden erstellt.",
|
||||
"combinationInfo": "{perCombination} NPCs pro Kombination × {combinations} Kombinationen = {count} NPCs insgesamt",
|
||||
"age": "Alter",
|
||||
"errorLoadingRegions": "Fehler beim Laden der Städte.",
|
||||
"errorLoadingTitles": "Fehler beim Laden der Titel.",
|
||||
"errorCreating": "Fehler beim Erstellen der NPCs.",
|
||||
"invalidAgeRange": "Ungültiger Altersbereich.",
|
||||
"invalidTitleRange": "Ungültiger Titel-Bereich.",
|
||||
"invalidCount": "Ungültige Anzahl (1-500).",
|
||||
"progress": "Fortschritt",
|
||||
"progressDetails": "{current} von {total} NPCs erstellt",
|
||||
"timeRemainingSeconds": "Verbleibende Zeit: {seconds} Sekunden",
|
||||
"timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden",
|
||||
"almostDone": "Fast fertig...",
|
||||
"jobNotFound": "Job nicht gefunden oder abgelaufen."
|
||||
"title": "Crear NPC",
|
||||
"region": "Ciudad",
|
||||
"allRegions": "Todas las ciudades",
|
||||
"ageRange": "Rango de edad",
|
||||
"to": "hasta",
|
||||
"years": "años",
|
||||
"titleRange": "Rango de títulos",
|
||||
"count": "Cantidad por combinación ciudad-título",
|
||||
"countHelp": "Esta cantidad se crea para cada combinación de ciudad y título seleccionados.",
|
||||
"create": "Crear NPC",
|
||||
"creating": "Creando...",
|
||||
"result": "Resultado",
|
||||
"createdCount": "Se han creado {count} NPC.",
|
||||
"combinationInfo": "{perCombination} NPC por combinación × {combinations} combinaciones = {count} NPC en total",
|
||||
"age": "Edad",
|
||||
"errorLoadingRegions": "Error al cargar las ciudades.",
|
||||
"errorLoadingTitles": "Error al cargar los títulos.",
|
||||
"errorCreating": "Error al crear los NPC.",
|
||||
"invalidAgeRange": "Rango de edad no válido.",
|
||||
"invalidTitleRange": "Rango de títulos no válido.",
|
||||
"invalidCount": "Cantidad no válida (1-500).",
|
||||
"progress": "Progreso",
|
||||
"progressDetails": "{current} de {total} NPC creados",
|
||||
"timeRemainingSeconds": "Tiempo restante: {seconds} segundos",
|
||||
"timeRemainingMinutes": "Tiempo restante: {minutes} minutos {seconds} segundos",
|
||||
"almostDone": "Casi listo...",
|
||||
"jobNotFound": "Trabajo no encontrado o caducado."
|
||||
}
|
||||
},
|
||||
"chatrooms": {
|
||||
"title": "[Admin] - Chaträume verwalten",
|
||||
"roomName": "Raumname",
|
||||
"create": "Chatraum anlegen",
|
||||
"edit": "Chatraum bearbeiten",
|
||||
"title": "[Admin] - Administrar salas de chat",
|
||||
"roomName": "Nombre de la sala",
|
||||
"create": "Crear sala de chat",
|
||||
"edit": "Editar sala de chat",
|
||||
"type": "Typ",
|
||||
"isPublic": "Öffentlich sichtbar",
|
||||
"actions": "Aktionen",
|
||||
"isPublic": "Visible públicamente",
|
||||
"actions": "Acciones",
|
||||
"genderRestriction": {
|
||||
"show": "Geschlechtsbeschränkung aktivieren",
|
||||
"label": "Geschlechtsbeschränkung"
|
||||
"show": "Activar restricción de género",
|
||||
"label": "Restricción de género"
|
||||
},
|
||||
"minAge": {
|
||||
"show": "Mindestalter angeben",
|
||||
"label": "Mindestalter"
|
||||
"show": "Indicar edad mínima",
|
||||
"label": "Edad mínima"
|
||||
},
|
||||
"maxAge": {
|
||||
"show": "Höchstalter angeben",
|
||||
"label": "Höchstalter"
|
||||
"show": "Indicar edad máxima",
|
||||
"label": "Edad máxima"
|
||||
},
|
||||
"password": {
|
||||
"show": "Passwortschutz aktivieren",
|
||||
"label": "Passwort"
|
||||
"show": "Activar protección con contraseña",
|
||||
"label": "Contraseña"
|
||||
},
|
||||
"friendsOfOwnerOnly": "Nur Freunde des Besitzers",
|
||||
"requiredUserRight": {
|
||||
"show": "Benötigtes Benutzerrecht angeben",
|
||||
"label": "Benötigtes Benutzerrecht"
|
||||
"show": "Indicar permiso de usuario requerido",
|
||||
"label": "Permiso de usuario requerido"
|
||||
},
|
||||
"roomtype": {
|
||||
"chat": "Reden",
|
||||
"dice": "Würfeln",
|
||||
"chat": "Chat",
|
||||
"dice": "Dados",
|
||||
"poker": "Poker",
|
||||
"hangman": "Hangman"
|
||||
},
|
||||
"rights": {
|
||||
"talk": "Reden",
|
||||
"scream": "Schreien",
|
||||
"whisper": "Flüstern",
|
||||
"start game": "Spiel starten",
|
||||
"open room": "Raum öffnen",
|
||||
"systemmessage": "Systemnachricht"
|
||||
"talk": "Hablar",
|
||||
"scream": "Gritar",
|
||||
"whisper": "Susurrar",
|
||||
"start game": "Iniciar juego",
|
||||
"open room": "Abrir sala",
|
||||
"systemmessage": "Mensaje del sistema"
|
||||
},
|
||||
"confirmDelete": "Soll dieser Chatraum wirklich gelöscht werden?"
|
||||
"confirmDelete": "¿De verdad quieres eliminar esta sala de chat?"
|
||||
},
|
||||
"match3": {
|
||||
"title": "Match3 Level verwalten",
|
||||
"newLevel": "Neues Level erstellen",
|
||||
"editLevel": "Level bearbeiten",
|
||||
"deleteLevel": "Level löschen",
|
||||
"confirmDelete": "Möchtest du dieses Level wirklich löschen?",
|
||||
"title": "Administrar niveles de Match3",
|
||||
"newLevel": "Crear nuevo nivel",
|
||||
"editLevel": "Editar nivel",
|
||||
"deleteLevel": "Eliminar nivel",
|
||||
"confirmDelete": "¿De verdad quieres eliminar este nivel?",
|
||||
"levelName": "Name",
|
||||
"levelDescription": "Beschreibung",
|
||||
"levelDescription": "Descripción",
|
||||
"boardWidth": "Breite",
|
||||
"boardHeight": "Höhe",
|
||||
"boardHeight": "Altura",
|
||||
"moveLimit": "Zug-Limit",
|
||||
"levelOrder": "Reihenfolge",
|
||||
"boardLayout": "Board-Layout",
|
||||
"tileTypes": "Verfügbare Tile-Typen",
|
||||
"actions": "Aktionen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"update": "Aktualisieren",
|
||||
"create": "Erstellen",
|
||||
"tileTypes": "Tipos de fichas disponibles",
|
||||
"actions": "Acciones",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"update": "Actualizar",
|
||||
"create": "Crear",
|
||||
"boardControls": {
|
||||
"fillAll": "Alle aktivieren",
|
||||
"clearAll": "Alle deaktivieren",
|
||||
"invert": "Invertieren"
|
||||
"fillAll": "Activar todo",
|
||||
"clearAll": "Desactivar todo",
|
||||
"invert": "Invertir"
|
||||
},
|
||||
"loading": "Lade Level...",
|
||||
"retry": "Erneut versuchen",
|
||||
"availableLevels": "Verfügbare Level: {count}",
|
||||
"loading": "Cargando niveles...",
|
||||
"retry": "Reintentar",
|
||||
"availableLevels": "Niveles disponibles: {count}",
|
||||
"levelFormat": "Level {number}: {name}",
|
||||
"levelObjectives": "Level-Objekte",
|
||||
"objectivesTitle": "Siegvoraussetzungen",
|
||||
"addObjective": "Objektiv hinzufügen",
|
||||
"removeObjective": "Entfernen",
|
||||
"addObjective": "Añadir objetivo",
|
||||
"removeObjective": "Eliminar",
|
||||
"objectiveType": "Typ",
|
||||
"objectiveTypeScore": "Punkte sammeln",
|
||||
"objectiveTypeMatches": "Matches machen",
|
||||
"objectiveTypeMoves": "Züge verwenden",
|
||||
"objectiveTypeMoves": "Usar movimientos",
|
||||
"objectiveTypeTime": "Zeit einhalten",
|
||||
"objectiveTypeSpecial": "Spezialziel",
|
||||
"objectiveOperator": "Operator",
|
||||
"operatorGreaterEqual": "Größer oder gleich (≥)",
|
||||
"operatorLessEqual": "Kleiner oder gleich (≤)",
|
||||
"operatorGreaterEqual": "Mayor o igual (≥)",
|
||||
"operatorLessEqual": "Menor o igual (≤)",
|
||||
"operatorEqual": "Gleich (=)",
|
||||
"operatorGreater": "Größer als (>)",
|
||||
"operatorLess": "Kleiner als (<)",
|
||||
"operatorGreater": "Mayor que (>)",
|
||||
"operatorLess": "Menor que (<)",
|
||||
"objectiveTarget": "Zielwert",
|
||||
"objectiveTargetPlaceholder": "z.B. 100",
|
||||
"objectiveOrder": "Reihenfolge",
|
||||
"objectiveOrderPlaceholder": "1, 2, 3...",
|
||||
"objectiveDescription": "Beschreibung",
|
||||
"objectiveDescription": "Descripción",
|
||||
"objectiveDescriptionPlaceholder": "z.B. Sammle 100 Punkte",
|
||||
"objectiveRequired": "Erforderlich für Level-Abschluss",
|
||||
"noObjectives": "Keine Siegvoraussetzungen definiert. Klicke auf 'Objektiv hinzufügen' um welche zu erstellen."
|
||||
"objectiveRequired": "Requerido para completar el nivel",
|
||||
"noObjectives": "No hay condiciones de victoria definidas. Haz clic en 'Añadir objetivo' para crear una."
|
||||
},
|
||||
"userStatistics": {
|
||||
"title": "[Admin] - Benutzerstatistiken",
|
||||
"totalUsers": "Gesamtanzahl Benutzer",
|
||||
"genderDistribution": "Geschlechterverteilung",
|
||||
"ageDistribution": "Altersverteilung"
|
||||
"title": "[Admin] - Estadísticas de usuarios",
|
||||
"totalUsers": "Total de usuarios",
|
||||
"genderDistribution": "Distribución por género",
|
||||
"ageDistribution": "Distribución por edad"
|
||||
},
|
||||
"taxiTools": {
|
||||
"title": "Taxi-Tools",
|
||||
"description": "Verwalte Taxi-Maps, Level und Konfigurationen",
|
||||
"description": "Administra mapas, niveles y configuraciones de Taxi",
|
||||
"mapEditor": {
|
||||
"title": "Map bearbeiten",
|
||||
"availableMaps": "Verfügbare Maps: {count}",
|
||||
"newMap": "Neue Map erstellen",
|
||||
"title": "Editar mapa",
|
||||
"availableMaps": "Mapas disponibles: {count}",
|
||||
"newMap": "Crear nuevo mapa",
|
||||
"mapFormat": "{name} (Position: {x},{y})",
|
||||
"mapName": "Map-Name",
|
||||
"mapDescription": "Beschreibung",
|
||||
"mapDescription": "Descripción",
|
||||
"mapWidth": "Breite",
|
||||
"mapHeight": "Höhe",
|
||||
"tileSize": "Tile-Größe",
|
||||
"mapHeight": "Altura",
|
||||
"tileSize": "Tamaño de las fichas",
|
||||
"positionX": "X-Position",
|
||||
"positionY": "Y-Position",
|
||||
"mapType": "Map-Typ",
|
||||
"mapLayout": "Map-Layout",
|
||||
"tilePalette": "Tile-Palette",
|
||||
"streetNames": "Straßennamen",
|
||||
"extraElements": "Zusätzliche Elemente",
|
||||
"streetNameHorizontal": "Straßenname (horizontal)",
|
||||
"streetNameVertical": "Straßenname (vertikal)",
|
||||
"continueHorizontal": "In anderer Richtung fortführen (→)",
|
||||
"continueVertical": "In anderer Richtung fortführen (↓)",
|
||||
"continueOther": "In anderer Richtung fortführen",
|
||||
"streetNames": "Nombres de calles",
|
||||
"extraElements": "Elementos adicionales",
|
||||
"streetNameHorizontal": "Nombre de calle (horizontal)",
|
||||
"streetNameVertical": "Nombre de calle (vertical)",
|
||||
"continueHorizontal": "Continuar en otra dirección (→)",
|
||||
"continueVertical": "Continuar en otra dirección (↓)",
|
||||
"continueOther": "Continuar en otra dirección",
|
||||
"position": "Position",
|
||||
"fillAllRoads": "Alle Straßen",
|
||||
"clearAll": "Alle löschen",
|
||||
"generateRandom": "Zufällig generieren",
|
||||
"delete": "Löschen",
|
||||
"update": "Aktualisieren",
|
||||
"cancel": "Abbrechen",
|
||||
"create": "Erstellen",
|
||||
"createSuccess": "Map wurde erfolgreich erstellt!",
|
||||
"updateSuccess": "Map wurde erfolgreich aktualisiert!",
|
||||
"deleteSuccess": "Map wurde erfolgreich gelöscht!"
|
||||
"fillAllRoads": "Todas las calles",
|
||||
"clearAll": "Borrar todo",
|
||||
"generateRandom": "Generar aleatoriamente",
|
||||
"delete": "Eliminar",
|
||||
"update": "Actualizar",
|
||||
"cancel": "Cancelar",
|
||||
"create": "Crear",
|
||||
"createSuccess": "¡El mapa se ha creado correctamente!",
|
||||
"updateSuccess": "¡El mapa se ha actualizado correctamente!",
|
||||
"deleteSuccess": "¡El mapa se ha eliminado correctamente!"
|
||||
}
|
||||
},
|
||||
"servicesStatus": {
|
||||
"title": "Service-Status",
|
||||
"description": "Überwache den Status von Backend, Chat und Daemon",
|
||||
"description": "Supervisa el estado del backend, el chat y el daemon",
|
||||
"status": {
|
||||
"connected": "Verbunden",
|
||||
"connecting": "Verbinde...",
|
||||
"disconnected": "Nicht verbunden",
|
||||
"error": "Fehler",
|
||||
"unknown": "Unbekannt"
|
||||
"connected": "Conectado",
|
||||
"connecting": "Conectando...",
|
||||
"disconnected": "Desconectado",
|
||||
"error": "Error",
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"backend": {
|
||||
"title": "Backend",
|
||||
"connected": "Backend-Service ist erreichbar und verbunden"
|
||||
"connected": "El servicio de backend está accesible y conectado"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chat",
|
||||
"connected": "Chat-Service ist erreichbar und verbunden"
|
||||
"connected": "El servicio de chat está accesible y conectado"
|
||||
},
|
||||
"daemon": {
|
||||
"title": "Daemon",
|
||||
"connected": "Daemon-Service ist erreichbar und verbunden",
|
||||
"connected": "El servicio daemon está accesible y conectado",
|
||||
"connections": {
|
||||
"title": "Aktive Verbindungen",
|
||||
"none": "Keine aktiven Verbindungen",
|
||||
"none": "No hay conexiones activas",
|
||||
"userId": "Benutzer-ID",
|
||||
"username": "Benutzername",
|
||||
"connections": "Verbindungen",
|
||||
@@ -321,21 +321,21 @@
|
||||
"pongReceived": "Pong empfangen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"notConnected": "Daemon nicht verbunden",
|
||||
"sendError": "Fehler beim Senden der Anfrage",
|
||||
"error": "Fehler beim Abrufen der Verbindungen"
|
||||
"notConnected": "Daemon no conectado",
|
||||
"sendError": "Error al enviar la solicitud",
|
||||
"error": "Error al obtener las conexiones"
|
||||
},
|
||||
"websocketLog": {
|
||||
"title": "WebSocket-Log",
|
||||
"showLog": "WebSocket-Log anzeigen",
|
||||
"refresh": "Aktualisieren",
|
||||
"loading": "Lädt...",
|
||||
"close": "Schließen",
|
||||
"entryCount": "{count} Einträge",
|
||||
"noEntries": "Keine Log-Einträge vorhanden",
|
||||
"notConnected": "Daemon nicht verbunden",
|
||||
"sendError": "Fehler beim Senden der Anfrage",
|
||||
"parseError": "Fehler beim Verarbeiten der Antwort",
|
||||
"loading": "Cargando...",
|
||||
"close": "Cerrar",
|
||||
"entryCount": "{count} entradas",
|
||||
"noEntries": "No hay entradas de registro",
|
||||
"notConnected": "Daemon no conectado",
|
||||
"sendError": "Error al enviar la solicitud",
|
||||
"parseError": "Error al procesar la respuesta",
|
||||
"timestamp": "Zeitstempel",
|
||||
"direction": "Richtung",
|
||||
"peer": "Peer",
|
||||
|
||||
@@ -37,6 +37,92 @@
|
||||
"connected": "Conectado",
|
||||
"disconnected": "Desconectado",
|
||||
"error": "Error de conexión"
|
||||
},
|
||||
"reloadRooms": "Recargar salas",
|
||||
"createRoom": {
|
||||
"toggleShowChat": "Mostrar chat",
|
||||
"toggleCreateRoom": "Crear sala",
|
||||
"title": "Crear nueva sala",
|
||||
"commandPrefix": "Comando",
|
||||
"labels": {
|
||||
"roomName": "Nombre de la sala",
|
||||
"visibility": "Visibilidad",
|
||||
"gender": "Género",
|
||||
"minAge": "Edad mínima",
|
||||
"maxAge": "Edad máxima",
|
||||
"password": "Contraseña",
|
||||
"rightId": "Permiso requerido",
|
||||
"typeId": "Tipo de sala",
|
||||
"friendsOnly": "friends_only=true"
|
||||
},
|
||||
"placeholders": {
|
||||
"roomName": "p. ej. Lounge",
|
||||
"password": "sin espacios"
|
||||
},
|
||||
"options": {
|
||||
"none": "(ninguno)",
|
||||
"visibilityPublic": "Pública",
|
||||
"visibilityPrivate": "Privada",
|
||||
"genderMale": "Masculino",
|
||||
"genderFemale": "Femenino",
|
||||
"genderAny": "Cualquiera / Sin restricción"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Crear sala",
|
||||
"reset": "Restablecer"
|
||||
},
|
||||
"validation": {
|
||||
"roomNameRequired": "El nombre de la sala es obligatorio.",
|
||||
"minAgeInvalid": "min_age debe ser >= 0.",
|
||||
"maxAgeInvalid": "max_age debe ser >= 0.",
|
||||
"ageRangeInvalid": "min_age no puede ser mayor que max_age.",
|
||||
"passwordSpaces": "La contraseña no debe contener espacios.",
|
||||
"rightIdInvalid": "right_id debe ser > 0.",
|
||||
"typeIdInvalid": "type_id debe ser > 0."
|
||||
},
|
||||
"messages": {
|
||||
"noConnection": "Sin conexión con el servidor de chat.",
|
||||
"invalidForm": "Corrige los datos del formulario de sala.",
|
||||
"roomNameMissing": "Introduce un nombre de sala.",
|
||||
"sent": "Creación de sala enviada: {command}",
|
||||
"created": "La sala \"{room}\" se ha creado correctamente.",
|
||||
"createNotConfirmed": "La sala \"{room}\" aún no está confirmada. Revisa la lista de salas."
|
||||
},
|
||||
"ownedRooms": {
|
||||
"title": "Mis salas creadas",
|
||||
"hint": "Eliminar con comando del daemon: /dr <sala> (alias: /delete_room <sala>)",
|
||||
"empty": "Aún no tienes salas propias.",
|
||||
"public": "public",
|
||||
"private": "private",
|
||||
"confirmDelete": "¿Seguro que quieres eliminar la sala \"{room}\"?",
|
||||
"deleteSent": "Comando de borrado enviado: /dr {room}",
|
||||
"deleteError": "No se pudo eliminar la sala."
|
||||
},
|
||||
"rights": {
|
||||
"mainadmin": "Administrador principal",
|
||||
"contactrequests": "Solicitudes de contacto",
|
||||
"users": "Usuarios",
|
||||
"userrights": "Permisos de usuario",
|
||||
"forum": "Foro",
|
||||
"interests": "Intereses",
|
||||
"falukant": "Falukant",
|
||||
"minigames": "Minijuegos",
|
||||
"match3": "Match3",
|
||||
"taxiTools": "Herramientas de Taxi",
|
||||
"chatrooms": "Salas de chat",
|
||||
"servicesStatus": "Estado de servicios"
|
||||
},
|
||||
"types": {}
|
||||
},
|
||||
"password": {
|
||||
"title": "Contraseña requerida",
|
||||
"inputLabel": "Introduce la contraseña",
|
||||
"submit": "Entrar en sala",
|
||||
"cancel": "Cancelar",
|
||||
"requiredPrompt": "La sala \"{room}\" está protegida por contraseña. Introduce la contraseña:",
|
||||
"invalidPrompt": "Contraseña incorrecta para \"{room}\". Inténtalo de nuevo:",
|
||||
"cancelled": "Se canceló el acceso a \"{room}\".",
|
||||
"empty": "La contraseña no puede estar vacía."
|
||||
}
|
||||
},
|
||||
"randomchat": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import i18n from './i18n';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import feedbackPlugin from './utils/feedback';
|
||||
|
||||
function getBrowserLanguage() {
|
||||
// Prüfe zuerst die bevorzugte Sprache
|
||||
@@ -56,5 +57,6 @@ app.use(store);
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
app.use(vuetify);
|
||||
app.use(feedbackPlugin);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import AdminInterestsView from '../views/admin/InterestsView.vue';
|
||||
import AdminContactsView from '../views/admin/ContactsView.vue';
|
||||
import RoomsView from '../views/admin/RoomsView.vue';
|
||||
import UserRightsView from '../views/admin/UserRightsView.vue';
|
||||
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
|
||||
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
|
||||
import AdminFalukantMapRegionsView from '../views/admin/falukant/MapRegionsView.vue';
|
||||
import AdminFalukantCreateNPCView from '../views/admin/falukant/CreateNPCView.vue';
|
||||
import AdminMinigamesView from '../views/admin/MinigamesView.vue';
|
||||
import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue';
|
||||
import AdminUsersView from '../views/admin/UsersView.vue';
|
||||
import UserStatisticsView from '../views/admin/UserStatisticsView.vue';
|
||||
import ServicesStatusView from '../views/admin/ServicesStatusView.vue';
|
||||
const AdminInterestsView = () => import('../views/admin/InterestsView.vue');
|
||||
const AdminContactsView = () => import('../views/admin/ContactsView.vue');
|
||||
const RoomsView = () => import('../views/admin/RoomsView.vue');
|
||||
const UserRightsView = () => import('../views/admin/UserRightsView.vue');
|
||||
const ForumAdminView = () => import('../dialogues/admin/ForumAdminView.vue');
|
||||
const AdminFalukantEditUserView = () => import('../views/admin/falukant/EditUserView.vue');
|
||||
const AdminFalukantMapRegionsView = () => import('../views/admin/falukant/MapRegionsView.vue');
|
||||
const AdminFalukantCreateNPCView = () => import('../views/admin/falukant/CreateNPCView.vue');
|
||||
const AdminMinigamesView = () => import('../views/admin/MinigamesView.vue');
|
||||
const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
|
||||
const AdminUsersView = () => import('../views/admin/UsersView.vue');
|
||||
const UserStatisticsView = () => import('../views/admin/UserStatisticsView.vue');
|
||||
const ServicesStatusView = () => import('../views/admin/ServicesStatusView.vue');
|
||||
|
||||
const adminRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import ActivateView from '../views/auth/ActivateView.vue';
|
||||
const ActivateView = () => import('../views/auth/ActivateView.vue');
|
||||
|
||||
const authRoutes = [
|
||||
{
|
||||
path: '/activate',
|
||||
name: 'Activate page',
|
||||
component: ActivateView
|
||||
component: ActivateView,
|
||||
meta: {
|
||||
robots: 'noindex, nofollow'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,13 +1,58 @@
|
||||
import BlogListView from '@/views/blog/BlogListView.vue';
|
||||
import BlogView from '@/views/blog/BlogView.vue';
|
||||
import BlogEditorView from '@/views/blog/BlogEditorView.vue';
|
||||
const BlogListView = () => import('@/views/blog/BlogListView.vue');
|
||||
const BlogView = () => import('@/views/blog/BlogView.vue');
|
||||
const BlogEditorView = () => import('@/views/blog/BlogEditorView.vue');
|
||||
import { buildAbsoluteUrl } from '@/utils/seo.js';
|
||||
|
||||
export default [
|
||||
{ path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } },
|
||||
{ path: '/blogs/:id/edit', name: 'BlogEdit', component: BlogEditorView, props: true, meta: { requiresAuth: true } },
|
||||
// Slug-only route first so it doesn't get captured by the :id route
|
||||
{ path: '/blogs/:slug', name: 'BlogSlug', component: BlogView, props: route => ({ slug: route.params.slug }) },
|
||||
{
|
||||
path: '/blogs/:slug',
|
||||
name: 'BlogSlug',
|
||||
component: BlogView,
|
||||
props: route => ({ slug: route.params.slug }),
|
||||
meta: {
|
||||
seo: {
|
||||
title: 'Blogs auf YourPart',
|
||||
description: 'Oeffentliche Blogs, Beitraege und Community-Inhalte auf YourPart.',
|
||||
},
|
||||
},
|
||||
},
|
||||
// Id-constrained route (numeric id only) with optional slug for canonical links
|
||||
{ path: '/blogs/:id(\\d+)/:slug?', name: 'Blog', component: BlogView, props: true },
|
||||
{ path: '/blogs', name: 'BlogList', component: BlogListView },
|
||||
{
|
||||
path: '/blogs/:id(\\d+)/:slug?',
|
||||
name: 'Blog',
|
||||
component: BlogView,
|
||||
props: true,
|
||||
meta: {
|
||||
seo: {
|
||||
title: 'Blogs auf YourPart',
|
||||
description: 'Oeffentliche Blogs, Beitraege und Community-Inhalte auf YourPart.',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/blogs',
|
||||
name: 'BlogList',
|
||||
component: BlogListView,
|
||||
meta: {
|
||||
seo: {
|
||||
title: 'Blogs auf YourPart - Community-Beitraege und Themen',
|
||||
description: 'Entdecke oeffentliche Blogs auf YourPart mit Community-Beitraegen, Gedanken, Erfahrungen und Themen aus verschiedenen Bereichen.',
|
||||
keywords: 'Blogs, Community Blog, Artikel, Beitraege, YourPart',
|
||||
canonicalPath: '/blogs',
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Blogs auf YourPart',
|
||||
url: buildAbsoluteUrl('/blogs'),
|
||||
description: 'Oeffentliche Blogs und Community-Beitraege auf YourPart.',
|
||||
inLanguage: 'de',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import BranchView from '../views/falukant/BranchView.vue';
|
||||
import Createview from '../views/falukant/CreateView.vue';
|
||||
import FalukantOverviewView from '../views/falukant/OverviewView.vue';
|
||||
import MoneyHistoryView from '../views/falukant/MoneyHistoryView.vue';
|
||||
import FamilyView from '../views/falukant/FamilyView.vue';
|
||||
import HouseView from '../views/falukant/HouseView.vue';
|
||||
import NobilityView from '../views/falukant/NobilityView.vue';
|
||||
import ReputationView from '../views/falukant/ReputationView.vue';
|
||||
import ChurchView from '../views/falukant/ChurchView.vue';
|
||||
import EducationView from '../views/falukant/EducationView.vue';
|
||||
import BankView from '../views/falukant/BankView.vue';
|
||||
import DirectorView from '../views/falukant/DirectorView.vue';
|
||||
import HealthView from '../views/falukant/HealthView.vue';
|
||||
import PoliticsView from '../views/falukant/PoliticsView.vue';
|
||||
import UndergroundView from '../views/falukant/UndergroundView.vue';
|
||||
const BranchView = () => import('../views/falukant/BranchView.vue');
|
||||
const Createview = () => import('../views/falukant/CreateView.vue');
|
||||
const FalukantOverviewView = () => import('../views/falukant/OverviewView.vue');
|
||||
const MoneyHistoryView = () => import('../views/falukant/MoneyHistoryView.vue');
|
||||
const FamilyView = () => import('../views/falukant/FamilyView.vue');
|
||||
const HouseView = () => import('../views/falukant/HouseView.vue');
|
||||
const NobilityView = () => import('../views/falukant/NobilityView.vue');
|
||||
const ReputationView = () => import('../views/falukant/ReputationView.vue');
|
||||
const ChurchView = () => import('../views/falukant/ChurchView.vue');
|
||||
const EducationView = () => import('../views/falukant/EducationView.vue');
|
||||
const BankView = () => import('../views/falukant/BankView.vue');
|
||||
const DirectorView = () => import('../views/falukant/DirectorView.vue');
|
||||
const HealthView = () => import('../views/falukant/HealthView.vue');
|
||||
const PoliticsView = () => import('../views/falukant/PoliticsView.vue');
|
||||
const UndergroundView = () => import('../views/falukant/UndergroundView.vue');
|
||||
|
||||
const falukantRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import store from '../store';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
import authRoutes from './authRoutes';
|
||||
import socialRoutes from './socialRoutes';
|
||||
import settingsRoutes from './settingsRoutes';
|
||||
@@ -9,13 +8,41 @@ import falukantRoutes from './falukantRoutes';
|
||||
import blogRoutes from './blogRoutes';
|
||||
import minigamesRoutes from './minigamesRoutes';
|
||||
import personalRoutes from './personalRoutes';
|
||||
import marketingRoutes from './marketingRoutes';
|
||||
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
|
||||
|
||||
const HomeView = () => import('../views/HomeView.vue');
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: HomeView
|
||||
component: HomeView,
|
||||
meta: {
|
||||
seo: {
|
||||
title: 'YourPart - Community, Chat, Forum, Blogs, Vokabeltrainer und Spiele',
|
||||
description: 'YourPart ist eine Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, dem Browser-Aufbauspiel Falukant und Minispielen.',
|
||||
keywords: 'YourPart, Community, Chat, Forum, Blogs, Vokabeltrainer, Browsergame, Falukant, Minispiele',
|
||||
canonicalPath: '/',
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'YourPart',
|
||||
url: buildAbsoluteUrl('/'),
|
||||
inLanguage: 'de',
|
||||
description: 'Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen.',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${buildAbsoluteUrl('/blogs')}?q={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
...marketingRoutes,
|
||||
...authRoutes,
|
||||
...socialRoutes,
|
||||
...settingsRoutes,
|
||||
@@ -45,4 +72,8 @@ router.beforeEach((to, from, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
applyRouteSeo(to);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
87
frontend/src/router/marketingRoutes.js
Normal file
87
frontend/src/router/marketingRoutes.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { buildAbsoluteUrl } from '../utils/seo';
|
||||
|
||||
const FalukantLandingView = () => import('../views/public/FalukantLandingView.vue');
|
||||
const MinigamesLandingView = () => import('../views/public/MinigamesLandingView.vue');
|
||||
const VocabLandingView = () => import('../views/public/VocabLandingView.vue');
|
||||
|
||||
const marketingRoutes = [
|
||||
{
|
||||
path: '/falukant',
|
||||
name: 'FalukantLanding',
|
||||
component: FalukantLandingView,
|
||||
meta: {
|
||||
seo: {
|
||||
title: 'Falukant - Mittelalterliches Browser-Aufbauspiel auf YourPart',
|
||||
description: 'Falukant ist das mittelalterliche Browser-Aufbauspiel auf YourPart mit Handel, Politik, Familie, Bildung und Charakterentwicklung.',
|
||||
keywords: 'Falukant, Browsergame, Aufbauspiel, Mittelalterspiel, Wirtschaftsspiel, Politikspiel, YourPart',
|
||||
canonicalPath: '/falukant',
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'VideoGame',
|
||||
name: 'Falukant',
|
||||
url: buildAbsoluteUrl('/falukant'),
|
||||
description: 'Mittelalterliches Browser-Aufbauspiel mit Handel, Politik, Familie und Charakterentwicklung.',
|
||||
gamePlatform: 'Web Browser',
|
||||
applicationCategory: 'Game',
|
||||
inLanguage: 'de',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'YourPart',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/minigames',
|
||||
name: 'MinigamesLanding',
|
||||
component: MinigamesLandingView,
|
||||
meta: {
|
||||
seo: {
|
||||
title: 'Minispiele auf YourPart - Match 3 und Taxi im Browser',
|
||||
description: 'Entdecke die Browser-Minispiele auf YourPart: Match 3 und Taxi bieten schnelle Spielrunden direkt auf der Plattform.',
|
||||
keywords: 'Minispiele, Browsergames, Match 3, Taxi Spiel, Casual Games, YourPart',
|
||||
canonicalPath: '/minigames',
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'YourPart Minispiele',
|
||||
url: buildAbsoluteUrl('/minigames'),
|
||||
description: 'Browser-Minispiele auf YourPart mit Match 3 und Taxi.',
|
||||
inLanguage: 'de',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/vokabeltrainer',
|
||||
name: 'VocabLanding',
|
||||
component: VocabLandingView,
|
||||
meta: {
|
||||
seo: {
|
||||
title: 'Vokabeltrainer auf YourPart - Sprachen online lernen',
|
||||
description: 'Der Vokabeltrainer auf YourPart unterstützt dich beim Sprachenlernen mit interaktiven Lektionen, Kursen und Übungen.',
|
||||
keywords: 'Vokabeltrainer, Sprachen lernen, Online lernen, Sprachkurse, Übungen, YourPart',
|
||||
canonicalPath: '/vokabeltrainer',
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'YourPart Vokabeltrainer',
|
||||
url: buildAbsoluteUrl('/vokabeltrainer'),
|
||||
description: 'Interaktiver Vokabeltrainer mit Kursen, Lektionen und Übungen zum Sprachenlernen.',
|
||||
applicationCategory: 'EducationalApplication',
|
||||
operatingSystem: 'Web',
|
||||
inLanguage: 'de',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default marketingRoutes;
|
||||
@@ -1,9 +1,9 @@
|
||||
import PeronalSettingsView from '../views/settings/PersonalView.vue';
|
||||
import ViewSettingsView from '../views/settings/ViewView.vue';
|
||||
import FlirtSettingsView from '../views/settings/FlirtView.vue';
|
||||
import SexualitySettingsView from '../views/settings/SexualityView.vue';
|
||||
import AccountSettingsView from '../views/settings/AccountView.vue';
|
||||
import InterestsView from '../views/settings/InterestsView.vue';
|
||||
const PeronalSettingsView = () => import('../views/settings/PersonalView.vue');
|
||||
const ViewSettingsView = () => import('../views/settings/ViewView.vue');
|
||||
const FlirtSettingsView = () => import('../views/settings/FlirtView.vue');
|
||||
const SexualitySettingsView = () => import('../views/settings/SexualityView.vue');
|
||||
const AccountSettingsView = () => import('../views/settings/AccountView.vue');
|
||||
const InterestsView = () => import('../views/settings/InterestsView.vue');
|
||||
|
||||
const settingsRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import FriendsView from '../views/social/FriendsView.vue';
|
||||
import SearchView from '../views/social/SearchView.vue';
|
||||
import GalleryView from '../views/social/GalleryView.vue';
|
||||
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';
|
||||
import VocabCourseListView from '../views/social/VocabCourseListView.vue';
|
||||
import VocabCourseView from '../views/social/VocabCourseView.vue';
|
||||
import VocabLessonView from '../views/social/VocabLessonView.vue';
|
||||
const FriendsView = () => import('../views/social/FriendsView.vue');
|
||||
const SearchView = () => import('../views/social/SearchView.vue');
|
||||
const GalleryView = () => import('../views/social/GalleryView.vue');
|
||||
const GuestbookView = () => import('../views/social/GuestbookView.vue');
|
||||
const DiaryView = () => import('../views/social/DiaryView.vue');
|
||||
const ForumView = () => import('../views/social/ForumView.vue');
|
||||
const ForumTopicView = () => import('../views/social/ForumTopicView.vue');
|
||||
const VocabTrainerView = () => import('../views/social/VocabTrainerView.vue');
|
||||
const VocabNewLanguageView = () => import('../views/social/VocabNewLanguageView.vue');
|
||||
const VocabLanguageView = () => import('../views/social/VocabLanguageView.vue');
|
||||
const VocabSubscribeView = () => import('../views/social/VocabSubscribeView.vue');
|
||||
const VocabChapterView = () => import('../views/social/VocabChapterView.vue');
|
||||
const VocabCourseListView = () => import('../views/social/VocabCourseListView.vue');
|
||||
const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
|
||||
const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
|
||||
|
||||
const socialRoutes = [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getChatWsUrlFromEnv } from '@/utils/appConfig.js';
|
||||
|
||||
// Small helper to resolve the Chat WebSocket URL from env or sensible defaults
|
||||
export function getChatWsUrl() {
|
||||
// Prefer explicit env var
|
||||
@@ -5,24 +7,7 @@ export function getChatWsUrl() {
|
||||
if (override && typeof override === 'string' && override.trim()) {
|
||||
return override.trim();
|
||||
}
|
||||
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL;
|
||||
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) {
|
||||
return envUrl.trim();
|
||||
}
|
||||
// Fallback: use current origin host with ws/wss and default port/path if provided by backend
|
||||
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const proto = isHttps ? 'wss' : 'ws';
|
||||
// If a reverse proxy exposes the chat at a path, you can change '/chat' here.
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
|
||||
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
|
||||
// On localhost, prefer dedicated chat port 1235 by default
|
||||
// Prefer IPv4 for localhost to avoid browsers resolving to ::1 (IPv6) where the server may not listen
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
|
||||
return `${proto}://127.0.0.1:1235`;
|
||||
}
|
||||
// Default to same origin with chat port for production
|
||||
const defaultUrl = `${proto}://${host}:1235`;
|
||||
return defaultUrl;
|
||||
return getChatWsUrlFromEnv();
|
||||
}
|
||||
|
||||
// Provide a list of candidate WS URLs to try, in order of likelihood.
|
||||
@@ -31,37 +16,8 @@ export function getChatWsCandidates() {
|
||||
if (override && typeof override === 'string' && override.trim()) {
|
||||
return [override.trim()];
|
||||
}
|
||||
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL;
|
||||
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) {
|
||||
return [envUrl.trim()];
|
||||
}
|
||||
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
const proto = isHttps ? 'wss' : 'ws';
|
||||
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
|
||||
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
|
||||
const candidates = [];
|
||||
// Common local setups: include IPv4 and IPv6 loopback variants (root only)
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
|
||||
// Prefer IPv6 loopback first when available
|
||||
const localHosts = ['[::1]', '127.0.0.1', 'localhost'];
|
||||
for (const h of localHosts) {
|
||||
const base = `${proto}://${h}:1235`;
|
||||
candidates.push(base);
|
||||
candidates.push(`${base}/`);
|
||||
}
|
||||
}
|
||||
// Same-origin with chat port
|
||||
const sameOriginBases = [`${proto}://${host}:1235`];
|
||||
// If localhost-ish, also try 127.0.0.1 for chat port
|
||||
if (host === 'localhost' || host === '::1' || host === '[::1]') {
|
||||
sameOriginBases.push(`${proto}://[::1]:1235`);
|
||||
sameOriginBases.push(`${proto}://127.0.0.1:1235`);
|
||||
}
|
||||
for (const base of sameOriginBases) {
|
||||
candidates.push(base);
|
||||
candidates.push(`${base}/`);
|
||||
}
|
||||
return candidates;
|
||||
const resolved = getChatWsUrlFromEnv();
|
||||
return [resolved, `${resolved}/`];
|
||||
}
|
||||
|
||||
// Return optional subprotocols for the WebSocket handshake.
|
||||
|
||||
@@ -4,6 +4,7 @@ import loadMenu from '../utils/menuLoader.js';
|
||||
import router from '../router';
|
||||
import apiClient from '../utils/axios.js';
|
||||
import { io } from 'socket.io-client';
|
||||
import { getDaemonSocketUrl, getSocketIoUrl } from '../utils/appConfig.js';
|
||||
|
||||
const store = createStore({
|
||||
state: {
|
||||
@@ -180,38 +181,7 @@ const store = createStore({
|
||||
|
||||
commit('setConnectionStatus', 'connecting');
|
||||
|
||||
// Socket.io URL für lokale Entwicklung und Produktion
|
||||
let socketIoUrl = import.meta.env.VITE_SOCKET_IO_URL || import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
// Für lokale Entwicklung: direkte Backend-Verbindung
|
||||
if (!socketIoUrl && (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
|
||||
socketIoUrl = 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Direkte Verbindung zu Socket.io (ohne Apache-Proxy)
|
||||
// In Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
|
||||
const hostname = window.location.hostname;
|
||||
const isProduction = hostname === 'www.your-part.de' || hostname.includes('your-part.de');
|
||||
|
||||
if (isProduction) {
|
||||
// Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
|
||||
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
||||
socketIoUrl = `${protocol}//${hostname}:4443`;
|
||||
} else {
|
||||
// Lokale Entwicklung: direkte Backend-Verbindung
|
||||
if (!socketIoUrl && (import.meta.env.DEV || hostname === 'localhost' || hostname === '127.0.0.1')) {
|
||||
socketIoUrl = 'http://localhost:3001';
|
||||
} else if (socketIoUrl) {
|
||||
try {
|
||||
const parsed = new URL(socketIoUrl, window.location.origin);
|
||||
socketIoUrl = parsed.origin;
|
||||
} catch (e) {
|
||||
socketIoUrl = window.location.origin;
|
||||
}
|
||||
} else {
|
||||
socketIoUrl = window.location.origin;
|
||||
}
|
||||
}
|
||||
let socketIoUrl = getSocketIoUrl();
|
||||
|
||||
// Socket.io-Konfiguration: In Produktion mit HTTPS verwenden wir wss://
|
||||
const socketOptions = {
|
||||
@@ -287,29 +257,7 @@ const store = createStore({
|
||||
|
||||
// Daemon URL für lokale Entwicklung und Produktion
|
||||
// Vite bindet Umgebungsvariablen zur Build-Zeit ein, daher Fallback-Logik basierend auf Hostname
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
|
||||
const isProduction = hostname === 'www.your-part.de' || hostname.includes('your-part.de');
|
||||
|
||||
// Versuche Umgebungsvariable zu lesen (kann undefined sein, wenn nicht zur Build-Zeit gesetzt)
|
||||
let daemonUrl = import.meta.env?.VITE_DAEMON_SOCKET;
|
||||
|
||||
console.log('[Daemon] Umgebungsvariable VITE_DAEMON_SOCKET:', daemonUrl);
|
||||
console.log('[Daemon] DEV-Modus:', import.meta.env?.DEV);
|
||||
console.log('[Daemon] Hostname:', hostname);
|
||||
console.log('[Daemon] IsLocalhost:', isLocalhost);
|
||||
console.log('[Daemon] IsProduction:', isProduction);
|
||||
|
||||
// Wenn Umgebungsvariable nicht gesetzt ist oder leer, verwende Fallback-Logik
|
||||
if (!daemonUrl || (typeof daemonUrl === 'string' && daemonUrl.trim() === '')) {
|
||||
// Immer direkte Verbindung zum Daemon-Port 4551 (verschlüsselt)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
daemonUrl = `${protocol}//${hostname}:4551/`;
|
||||
console.log('[Daemon] Verwende direkte Verbindung zu Port 4551');
|
||||
} else {
|
||||
// Wenn Umgebungsvariable gesetzt ist, verwende sie direkt
|
||||
console.log('[Daemon] Verwende Umgebungsvariable:', daemonUrl);
|
||||
}
|
||||
let daemonUrl = getDaemonSocketUrl();
|
||||
|
||||
console.log('[Daemon] Finale Daemon-URL:', daemonUrl);
|
||||
|
||||
|
||||
61
frontend/src/utils/appConfig.js
Normal file
61
frontend/src/utils/appConfig.js
Normal file
@@ -0,0 +1,61 @@
|
||||
function trimTrailingSlash(value) {
|
||||
return value ? value.replace(/\/$/, '') : value;
|
||||
}
|
||||
|
||||
function getWindowOrigin() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
function toWsOrigin(value) {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/^http:\/\//i, 'ws://')
|
||||
.replace(/^https:\/\//i, 'wss://');
|
||||
}
|
||||
|
||||
export function getApiBaseUrl() {
|
||||
return trimTrailingSlash(import.meta.env.VITE_API_BASE_URL || getWindowOrigin() || '');
|
||||
}
|
||||
|
||||
export function getSocketIoUrl() {
|
||||
return trimTrailingSlash(import.meta.env.VITE_SOCKET_IO_URL || getApiBaseUrl() || getWindowOrigin() || '');
|
||||
}
|
||||
|
||||
export function getDaemonSocketUrl() {
|
||||
const configured = import.meta.env.VITE_DAEMON_SOCKET;
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
|
||||
return toWsOrigin(getWindowOrigin());
|
||||
}
|
||||
|
||||
export function getPublicBaseUrl() {
|
||||
return trimTrailingSlash(import.meta.env.VITE_PUBLIC_BASE_URL || getWindowOrigin() || 'https://www.your-part.de');
|
||||
}
|
||||
|
||||
export function getChatWsUrlFromEnv() {
|
||||
const directUrl = import.meta.env.VITE_CHAT_WS_URL;
|
||||
if (directUrl) {
|
||||
return directUrl.trim();
|
||||
}
|
||||
|
||||
const host = import.meta.env.VITE_CHAT_WS_HOST;
|
||||
const port = import.meta.env.VITE_CHAT_WS_PORT;
|
||||
const protocol = import.meta.env.VITE_CHAT_WS_PROTOCOL || (typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss' : 'ws');
|
||||
|
||||
if (host || port) {
|
||||
const resolvedHost = host || (typeof window !== 'undefined' ? window.location.hostname : 'localhost');
|
||||
const resolvedPort = port ? `:${port}` : '';
|
||||
return `${protocol}://${resolvedHost}${resolvedPort}`;
|
||||
}
|
||||
|
||||
return toWsOrigin(getWindowOrigin());
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import store from '../store';
|
||||
import { getApiBaseUrl } from './appConfig.js';
|
||||
|
||||
// API-Basis-URL - Apache-Proxy für Produktion, direkte Verbindung für lokale Entwicklung
|
||||
const getApiBaseURL = () => {
|
||||
// Wenn explizite Umgebungsvariable gesetzt ist, diese verwenden
|
||||
if (import.meta.env.VITE_API_BASE_URL) {
|
||||
return import.meta.env.VITE_API_BASE_URL;
|
||||
}
|
||||
|
||||
// Für lokale Entwicklung: direkte Backend-Verbindung
|
||||
if (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||
return 'http://localhost:3001';
|
||||
}
|
||||
|
||||
// Für Produktion: Root-Pfad, da API-Endpunkte bereits mit /api beginnen
|
||||
return '';
|
||||
return getApiBaseUrl();
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Centralized config for YourChat protocol mapping and WS endpoint
|
||||
// Override via .env (VITE_* variables)
|
||||
import { getChatWsUrlFromEnv } from './appConfig.js';
|
||||
|
||||
const env = import.meta.env || {};
|
||||
|
||||
export const CHAT_WS_URL = env.VITE_CHAT_WS_URL
|
||||
|| (env.VITE_CHAT_WS_HOST || env.VITE_CHAT_WS_PORT
|
||||
? `ws://${env.VITE_CHAT_WS_HOST || 'localhost'}:${env.VITE_CHAT_WS_PORT || '1235'}`
|
||||
: (typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host + '/socket.io/');
|
||||
export const CHAT_WS_URL = getChatWsUrlFromEnv();
|
||||
|
||||
// Event/type keys
|
||||
export const CHAT_EVENT_KEY = env.VITE_CHAT_EVENT_KEY || 'type';
|
||||
|
||||
67
frontend/src/utils/feedback.js
Normal file
67
frontend/src/utils/feedback.js
Normal file
@@ -0,0 +1,67 @@
|
||||
function getVm(context) {
|
||||
if (!context) return null;
|
||||
return context.proxy || context;
|
||||
}
|
||||
|
||||
function getRootRefs(context) {
|
||||
const vm = getVm(context);
|
||||
return vm?.$root?.$refs || {};
|
||||
}
|
||||
|
||||
function normalizeMessage(message, fallback = 'tr:error.network') {
|
||||
if (!message) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function showMessage(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
|
||||
const refs = getRootRefs(context);
|
||||
refs.messageDialog?.open?.(normalizeMessage(message, 'tr:message.title'), title, parameters, onClose);
|
||||
}
|
||||
|
||||
export function showSuccess(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
|
||||
showMessage(context, message, title, parameters, onClose);
|
||||
}
|
||||
|
||||
export function showInfo(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
|
||||
showMessage(context, message, title, parameters, onClose);
|
||||
}
|
||||
|
||||
export function showError(context, message, fallback = 'tr:error.network') {
|
||||
const refs = getRootRefs(context);
|
||||
refs.errorDialog?.open?.(normalizeMessage(message, fallback));
|
||||
}
|
||||
|
||||
export function showApiError(context, error, fallback = 'tr:error.network') {
|
||||
const responseError = error?.response?.data?.error;
|
||||
|
||||
if (typeof responseError === 'string') {
|
||||
const normalized = responseError.startsWith('tr:') || responseError.includes(' ')
|
||||
? responseError
|
||||
: `tr:error.${responseError}`;
|
||||
showError(context, normalized, fallback);
|
||||
return;
|
||||
}
|
||||
|
||||
showError(context, fallback, fallback);
|
||||
}
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
const getAppContext = () => app._instance?.proxy;
|
||||
|
||||
app.config.globalProperties.$feedback = {
|
||||
showMessage: (...args) => showMessage(getAppContext(), ...args),
|
||||
showSuccess: (...args) => showSuccess(getAppContext(), ...args),
|
||||
showInfo: (...args) => showInfo(getAppContext(), ...args),
|
||||
showError: (...args) => showError(getAppContext(), ...args),
|
||||
showApiError: (...args) => showApiError(getAppContext(), ...args)
|
||||
};
|
||||
}
|
||||
};
|
||||
160
frontend/src/utils/seo.js
Normal file
160
frontend/src/utils/seo.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { getPublicBaseUrl } from './appConfig.js';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://www.your-part.de';
|
||||
const DEFAULT_SITE_NAME = 'YourPart';
|
||||
const DEFAULT_TITLE = 'YourPart - Community, Chat, Forum, Vokabeltrainer, Falukant und Minispiele';
|
||||
const DEFAULT_DESCRIPTION = 'YourPart verbindet Community, Chat, Forum, Blogs, Vokabeltrainer, das Aufbauspiel Falukant und Browser-Minispiele auf einer Plattform.';
|
||||
const DEFAULT_IMAGE = `${DEFAULT_BASE_URL}/images/logos/logo.png`;
|
||||
const MANAGED_META_KEYS = [
|
||||
['name', 'description'],
|
||||
['name', 'keywords'],
|
||||
['name', 'robots'],
|
||||
['name', 'twitter:card'],
|
||||
['name', 'twitter:title'],
|
||||
['name', 'twitter:description'],
|
||||
['name', 'twitter:image'],
|
||||
['property', 'og:type'],
|
||||
['property', 'og:site_name'],
|
||||
['property', 'og:title'],
|
||||
['property', 'og:description'],
|
||||
['property', 'og:url'],
|
||||
['property', 'og:locale'],
|
||||
['property', 'og:image'],
|
||||
];
|
||||
|
||||
function getBaseUrl() {
|
||||
return getPublicBaseUrl().replace(/\/$/, '') || DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
function upsertMeta(attr, key, content) {
|
||||
let element = document.head.querySelector(`meta[${attr}="${key}"]`);
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
element.setAttribute(attr, key);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('content', content);
|
||||
}
|
||||
|
||||
function upsertLink(rel, href) {
|
||||
let element = document.head.querySelector(`link[rel="${rel}"]`);
|
||||
if (!element) {
|
||||
element = document.createElement('link');
|
||||
element.setAttribute('rel', rel);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('href', href);
|
||||
}
|
||||
|
||||
function clearManagedJsonLd() {
|
||||
document.head.querySelectorAll('script[data-seo-managed="true"]').forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
export function buildAbsoluteUrl(path = '/') {
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${getBaseUrl()}${normalizedPath}`;
|
||||
}
|
||||
|
||||
export function stripHtml(html = '') {
|
||||
return html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function truncateText(text = '', maxLength = 160) {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return `${text.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
export function createBlogSlug(ownerUsername = '', blogTitle = '') {
|
||||
const usernamePart = String(ownerUsername || '').trim();
|
||||
const titlePart = String(blogTitle || '')
|
||||
.replace(/\s+/g, '')
|
||||
.trim();
|
||||
|
||||
return `${usernamePart}${titlePart}`.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
}
|
||||
|
||||
export function applySeo(overrides = {}) {
|
||||
const title = overrides.title || DEFAULT_TITLE;
|
||||
const description = overrides.description || DEFAULT_DESCRIPTION;
|
||||
const keywords = overrides.keywords || 'YourPart, Community, Chat, Forum, Blog, Vokabeltrainer, Falukant, Minispiele';
|
||||
const robots = overrides.robots || 'index, follow';
|
||||
const canonical = buildAbsoluteUrl(overrides.canonicalPath || '/');
|
||||
const image = buildAbsoluteUrl(overrides.image || DEFAULT_IMAGE);
|
||||
const type = overrides.type || 'website';
|
||||
const locale = overrides.locale || 'de_DE';
|
||||
|
||||
document.title = title;
|
||||
document.documentElement.setAttribute('lang', overrides.lang || 'de');
|
||||
|
||||
upsertMeta('name', 'description', description);
|
||||
upsertMeta('name', 'keywords', keywords);
|
||||
upsertMeta('name', 'robots', robots);
|
||||
upsertMeta('name', 'twitter:card', overrides.twitterCard || 'summary_large_image');
|
||||
upsertMeta('name', 'twitter:title', overrides.twitterTitle || title);
|
||||
upsertMeta('name', 'twitter:description', overrides.twitterDescription || description);
|
||||
upsertMeta('name', 'twitter:image', image);
|
||||
upsertMeta('property', 'og:type', type);
|
||||
upsertMeta('property', 'og:site_name', DEFAULT_SITE_NAME);
|
||||
upsertMeta('property', 'og:title', overrides.ogTitle || title);
|
||||
upsertMeta('property', 'og:description', overrides.ogDescription || description);
|
||||
upsertMeta('property', 'og:url', canonical);
|
||||
upsertMeta('property', 'og:locale', locale);
|
||||
upsertMeta('property', 'og:image', image);
|
||||
upsertLink('canonical', canonical);
|
||||
|
||||
clearManagedJsonLd();
|
||||
for (const payload of overrides.jsonLd || []) {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.dataset.seoManaged = 'true';
|
||||
script.textContent = JSON.stringify(payload);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRouteSeo(route) {
|
||||
const seo = route.meta?.seo || {};
|
||||
const isProtected = !!route.meta?.requiresAuth;
|
||||
|
||||
const title = seo.title || DEFAULT_TITLE;
|
||||
const description = seo.description || DEFAULT_DESCRIPTION;
|
||||
const canonicalPath = seo.canonicalPath || route.path || '/';
|
||||
const robots = seo.robots || route.meta?.robots || (isProtected ? 'noindex, nofollow' : 'index, follow');
|
||||
|
||||
applySeo({
|
||||
title,
|
||||
description,
|
||||
canonicalPath,
|
||||
keywords: seo.keywords,
|
||||
robots,
|
||||
type: seo.type,
|
||||
image: seo.image,
|
||||
locale: seo.locale,
|
||||
lang: seo.lang,
|
||||
jsonLd: isProtected ? [] : (seo.jsonLd || []),
|
||||
});
|
||||
}
|
||||
|
||||
export function resetSeo() {
|
||||
for (const [attr, key] of MANAGED_META_KEYS) {
|
||||
const element = document.head.querySelector(`meta[${attr}="${key}"]`);
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
document.head.querySelector('link[rel="canonical"]')?.remove();
|
||||
clearManagedJsonLd();
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import HomeNoLoginView from './home/NoLoginView.vue';
|
||||
import HomeLoggedInView from './home/LoggedInView.vue';
|
||||
|
||||
const HomeNoLoginView = defineAsyncComponent(() => import('./home/NoLoginView.vue'));
|
||||
const HomeLoggedInView = defineAsyncComponent(() => import('./home/LoggedInView.vue'));
|
||||
|
||||
export default {
|
||||
name: 'HomeView',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<tbody>
|
||||
<tr v-for="room in rooms" :key="room.id">
|
||||
<td>{{ room.title }}</td>
|
||||
<td>{{ room.roomTypeTr || room.roomTypeId }}</td>
|
||||
<td>{{ getRoomTypeLabel(room) }}</td>
|
||||
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
|
||||
<td>
|
||||
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
|
||||
@@ -48,6 +48,14 @@ export default {
|
||||
this.fetchRooms();
|
||||
},
|
||||
methods: {
|
||||
getRoomTypeLabel(room) {
|
||||
const roomTypeTr = room?.roomType?.tr || room?.roomTypeTr;
|
||||
if (!roomTypeTr) {
|
||||
return room?.roomTypeId ?? '-';
|
||||
}
|
||||
const translationKey = `admin.chatrooms.roomtype.${roomTypeTr}`;
|
||||
return this.$te(translationKey) ? this.$t(translationKey) : roomTypeTr;
|
||||
},
|
||||
openCreateDialog() {
|
||||
this.selectedRoom = null;
|
||||
this.$refs.roomDialog.open();
|
||||
|
||||
@@ -10,6 +10,36 @@
|
||||
|
||||
<!-- Match3 Levels Tab -->
|
||||
<div v-if="activeTab === 'match3-levels'" class="match3-admin">
|
||||
<section class="workflow-hero surface-card">
|
||||
<div>
|
||||
<span class="workflow-hero__eyebrow">Arbeitsfluss</span>
|
||||
<h2>{{ $t('admin.match3.title') }}</h2>
|
||||
<p>Erst Level waehlen, dann Spielfeld und Ziele anpassen und erst am Ende speichern.</p>
|
||||
</div>
|
||||
<div class="workflow-hero__meta">
|
||||
<span class="workflow-pill">{{ currentModeLabel }}</span>
|
||||
<span class="workflow-pill">{{ levels.length }} Level</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workflow-grid">
|
||||
<article class="workflow-card surface-card">
|
||||
<span class="workflow-card__step">1</span>
|
||||
<h3>Level waehlen</h3>
|
||||
<p>Bestehendes Level oeffnen oder sofort mit einer neuen Vorlage starten.</p>
|
||||
</article>
|
||||
<article class="workflow-card surface-card">
|
||||
<span class="workflow-card__step">2</span>
|
||||
<h3>Spielfeld bauen</h3>
|
||||
<p>Groesse, Zuege, Kacheln und Layout zuerst festziehen, bevor Ziele folgen.</p>
|
||||
</article>
|
||||
<article class="workflow-card surface-card">
|
||||
<span class="workflow-card__step">3</span>
|
||||
<h3>Ziele speichern</h3>
|
||||
<p>Objectives nur dann scharf stellen, wenn Grunddaten und Board bereits stimmen.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="section-header">
|
||||
<h2>{{ $t('admin.match3.title') }}</h2>
|
||||
</div>
|
||||
@@ -31,13 +61,38 @@
|
||||
{{ $t('admin.match3.levelFormat', { number: level.order, name: level.name }) }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary level-select-action" @click="createLevel">
|
||||
{{ $t('admin.match3.newLevel') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="level-selection__hint">
|
||||
{{ isCreatingLevel ? 'Du erstellst gerade ein neues Level.' : 'Du bearbeitest ein bestehendes Level mit allen verbundenen Objectives.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="admin-summary-grid">
|
||||
<article class="admin-summary-card surface-card">
|
||||
<span class="admin-summary-card__label">Modus</span>
|
||||
<strong>{{ currentModeLabel }}</strong>
|
||||
<p>{{ selectedLevel ? selectedLevel.name : 'Neue Vorlage mit leerem Spielfeld' }}</p>
|
||||
</article>
|
||||
<article class="admin-summary-card surface-card">
|
||||
<span class="admin-summary-card__label">Spielfeld</span>
|
||||
<strong>{{ levelForm.boardWidth }} x {{ levelForm.boardHeight }}</strong>
|
||||
<p>{{ levelForm.moveLimit }} Zuege, {{ levelForm.tileTypes.length }} aktive Tile-Typen.</p>
|
||||
</article>
|
||||
<article class="admin-summary-card surface-card">
|
||||
<span class="admin-summary-card__label">Objectives</span>
|
||||
<strong>{{ objectiveCount }}</strong>
|
||||
<p>{{ objectiveCount ? 'Ziele vorhanden und bearbeitbar.' : 'Noch keine Zieldefinition hinterlegt.' }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Level Details -->
|
||||
<div v-if="selectedLevelId !== 'new' && selectedLevel" class="level-details">
|
||||
<div class="details-header">
|
||||
<h3>{{ selectedLevel.name }}</h3>
|
||||
<p>Bestehendes Level anpassen, ohne den Kontext des aktuellen Spielflusses zu verlieren.</p>
|
||||
</div>
|
||||
<div class="details-content">
|
||||
<div class="form-group">
|
||||
@@ -185,7 +240,7 @@
|
||||
<button type="button" class="btn btn-danger" @click="deleteSelectedLevel">
|
||||
{{ $t('admin.match3.delete') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="saveLevel">
|
||||
<button type="button" class="btn btn-primary" :disabled="!isLevelFormValid" @click="saveLevel">
|
||||
{{ $t('admin.match3.update') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -539,7 +594,7 @@
|
||||
<button type="button" class="btn btn-secondary" @click="cancelEdit">
|
||||
{{ $t('admin.match3.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary" :disabled="!isLevelFormValid">
|
||||
{{ $t('admin.match3.create') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -553,6 +608,7 @@
|
||||
<script>
|
||||
import SimpleTabs from '../../components/SimpleTabs.vue';
|
||||
import apiClient from '../../utils/axios.js';
|
||||
import { showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'AdminMinigamesView',
|
||||
@@ -593,10 +649,29 @@ export default {
|
||||
gridTemplateRows: `repeat(${this.levelForm.boardHeight}, 1fr)`
|
||||
};
|
||||
},
|
||||
|
||||
isCreatingLevel() {
|
||||
return this.selectedLevelId === 'new';
|
||||
},
|
||||
selectedLevel() {
|
||||
if (this.selectedLevelId === 'new') return null;
|
||||
return this.levels.find(l => l.id === this.selectedLevelId);
|
||||
},
|
||||
objectiveCount() {
|
||||
return this.levelForm.objectives?.length || 0;
|
||||
},
|
||||
currentModeLabel() {
|
||||
return this.isCreatingLevel ? 'Neues Level' : 'Level bearbeiten';
|
||||
},
|
||||
isLevelFormValid() {
|
||||
return Boolean(
|
||||
this.levelForm.name?.trim() &&
|
||||
this.levelForm.description?.trim() &&
|
||||
this.levelForm.boardWidth >= 3 &&
|
||||
this.levelForm.boardHeight >= 3 &&
|
||||
this.levelForm.moveLimit >= 5 &&
|
||||
this.levelForm.order >= 1 &&
|
||||
this.levelForm.tileTypes?.length
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -730,20 +805,14 @@ export default {
|
||||
},
|
||||
|
||||
setTileType(index, tileType) {
|
||||
console.log('setTileType called with:', index, tileType);
|
||||
if (tileType === 'o') {
|
||||
// Leer
|
||||
this.boardMatrix[index] = { active: false, tileType: 'o', index: index };
|
||||
} else if (tileType === 'r') {
|
||||
// Zufällig
|
||||
this.boardMatrix[index] = { active: true, tileType: 'r', index: index };
|
||||
console.log('Set random tile at index:', index, this.boardMatrix[index]);
|
||||
} else {
|
||||
// Spezifischer Tile-Typ
|
||||
this.boardMatrix[index] = { active: true, tileType: tileType, index: index };
|
||||
}
|
||||
this.selectedCellIndex = null; // Auswahl aufheben
|
||||
console.log('Board matrix after update:', this.boardMatrix);
|
||||
this.selectedCellIndex = null;
|
||||
},
|
||||
|
||||
// Mapping für Tile-Typen zu Zeichen
|
||||
@@ -785,7 +854,6 @@ export default {
|
||||
objectives: []
|
||||
};
|
||||
this.updateBoardMatrix();
|
||||
console.log('Bearbeitung abgebrochen, Objectives zurückgesetzt:', this.levelForm.objectives);
|
||||
},
|
||||
|
||||
updateBoardMatrix() {
|
||||
@@ -905,6 +973,7 @@ export default {
|
||||
...this.levelForm,
|
||||
boardLayout: this.generateBoardLayout()
|
||||
};
|
||||
const wasCreating = this.selectedLevelId === 'new';
|
||||
|
||||
let savedLevel;
|
||||
if (this.selectedLevelId !== 'new') {
|
||||
@@ -939,9 +1008,10 @@ export default {
|
||||
this.selectedLevelId = 'new';
|
||||
this.selectedCellIndex = null;
|
||||
this.loadLevels();
|
||||
showSuccess(this, wasCreating ? 'Level wurde erstellt.' : 'Level wurde aktualisiert.');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Levels:', error);
|
||||
alert('Fehler beim Speichern des Levels');
|
||||
showError(this, 'Fehler beim Speichern des Levels');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -950,8 +1020,10 @@ export default {
|
||||
try {
|
||||
await apiClient.delete(`/api/admin/minigames/match3/levels/${levelId}`);
|
||||
this.loadLevels();
|
||||
showSuccess(this, 'Level wurde geloescht.');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Levels:', error);
|
||||
showError(this, 'Fehler beim Loeschen des Levels');
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1025,6 +1097,94 @@ export default {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.workflow-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 22px 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workflow-hero h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.workflow-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.workflow-hero__eyebrow,
|
||||
.admin-summary-card__label {
|
||||
display: inline-flex;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workflow-hero__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workflow-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workflow-grid,
|
||||
.admin-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.workflow-card,
|
||||
.admin-summary-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.workflow-card__step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workflow-card h3,
|
||||
.admin-summary-card strong {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.admin-summary-card strong {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.workflow-card p,
|
||||
.admin-summary-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
@@ -1055,6 +1215,8 @@ export default {
|
||||
.level-dropdown {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.level-select {
|
||||
@@ -1072,6 +1234,15 @@ export default {
|
||||
border-color: #F9A22C;
|
||||
}
|
||||
|
||||
.level-select-action {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.level-selection__hint {
|
||||
margin: 12px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Level Details & Form */
|
||||
.level-details,
|
||||
.level-form {
|
||||
@@ -1096,6 +1267,11 @@ export default {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.details-header p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -1480,6 +1656,16 @@ export default {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.workflow-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.workflow-grid,
|
||||
.admin-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<tbody>
|
||||
<tr v-for="room in rooms" :key="room.id">
|
||||
<td>{{ room.title }}</td>
|
||||
<td>{{ room.roomTypeTr || room.roomTypeId }}</td>
|
||||
<td>{{ getRoomTypeLabel(room) }}</td>
|
||||
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
|
||||
<td>
|
||||
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
|
||||
@@ -68,6 +68,14 @@ export default {
|
||||
const res = await apiClient.get('/api/admin/chat/rooms');
|
||||
this.rooms = res.data;
|
||||
},
|
||||
getRoomTypeLabel(room) {
|
||||
const roomTypeTr = room?.roomType?.tr || room?.roomTypeTr;
|
||||
if (!roomTypeTr) {
|
||||
return room?.roomTypeId ?? '-';
|
||||
}
|
||||
const translationKey = `admin.chatrooms.roomtype.${roomTypeTr}`;
|
||||
return this.$te(translationKey) ? this.$t(translationKey) : roomTypeTr;
|
||||
},
|
||||
async saveRoom(roomData) {
|
||||
// Remove forbidden and associated object fields before sending to backend
|
||||
const { id, ownerId, passwordHash, roomType, genderRestriction, ...cleanData } = roomData;
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
<template>
|
||||
<div class="admin-users">
|
||||
<section class="admin-users__hero surface-card">
|
||||
<span class="admin-users__eyebrow">Administration</span>
|
||||
<h1>{{ $t('navigation.m-administration.useradministration') }}</h1>
|
||||
<p>Benutzer suchen, Kerndaten anpassen und Sperrstatus direkt im System pflegen.</p>
|
||||
</section>
|
||||
|
||||
<section class="admin-users__search surface-card">
|
||||
<AdminUserSearch @select="select" />
|
||||
</section>
|
||||
|
||||
<div v-if="selected" class="edit">
|
||||
<section v-if="selected" class="edit surface-card">
|
||||
<div class="edit__header">
|
||||
<h2>{{ selected.username }}</h2>
|
||||
<label>
|
||||
{{ $t('admin.user.name') }}
|
||||
<span class="edit__badge">{{ form.active ? 'Aktiv' : 'Gesperrt' }}</span>
|
||||
</div>
|
||||
|
||||
<label class="edit__field">
|
||||
<span>{{ $t('admin.user.name') }}</span>
|
||||
<input v-model="form.username" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('admin.user.blocked') }}
|
||||
|
||||
<label class="edit__toggle">
|
||||
<input type="checkbox" :checked="!form.active" @change="toggleBlocked($event)" />
|
||||
<span>{{ $t('admin.user.blocked') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="save">{{ $t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,12 +69,105 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-users { padding: 20px; }
|
||||
.results table { width: 100%; border-collapse: collapse; }
|
||||
.results th, .results td { border: 1px solid #ddd; padding: 8px; }
|
||||
.edit { margin-top: 16px; display: grid; gap: 10px; max-width: 480px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
button { cursor: pointer; }
|
||||
.admin-users {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.admin-users__hero,
|
||||
.admin-users__search,
|
||||
.edit {
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.admin-users__hero,
|
||||
.admin-users__search,
|
||||
.edit {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.admin-users__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-users__hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.edit {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.edit__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.edit__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.edit__field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.edit__field span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.edit__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.admin-users__hero,
|
||||
.admin-users__search,
|
||||
.edit {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.edit__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
<!-- Benutzer-Suche -->
|
||||
<div class="search-section">
|
||||
<label>{{ $t('admin.falukant.edituser.username') }}: <input type="text" v-model="user.username" @keyup.enter="searchUser" /></label>
|
||||
<label>{{ $t('admin.falukant.edituser.characterName') }}: <input type="text" v-model="user.characterName" @keyup.enter="searchUser" /></label>
|
||||
<button @click="searchUser">{{ $t('admin.falukant.edituser.search') }}</button>
|
||||
<label class="form-field">{{ $t('admin.falukant.edituser.username') }} <input type="text" v-model="user.username" @keyup.enter="searchUser" /></label>
|
||||
<label class="form-field">{{ $t('admin.falukant.edituser.characterName') }} <input type="text" v-model="user.characterName" @keyup.enter="searchUser" /></label>
|
||||
<button @click="searchUser" :disabled="!canSearch">{{ $t('admin.falukant.edituser.search') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Benutzer-Liste -->
|
||||
@@ -40,8 +40,8 @@
|
||||
</select>
|
||||
</label>
|
||||
<div class="action-buttons">
|
||||
<button @click="saveUser">{{ $t('common.save') }}</button>
|
||||
<button @click="deleteUser">{{ $t('common.delete') }}</button>
|
||||
<button @click="saveUser" :disabled="!hasUserChanges">{{ $t('common.save') }}</button>
|
||||
<button @click="deleteUser" class="button-secondary">{{ $t('common.delete') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,6 +122,7 @@ import { mapState } from 'vuex';
|
||||
import { mapActions } from 'vuex';
|
||||
import SimpleTabs from '@/components/SimpleTabs.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: 'AdminFalukantEditUserView',
|
||||
@@ -162,6 +163,15 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapState('falukant', ['user']),
|
||||
canSearch() {
|
||||
return this.user.username.trim().length > 0 || this.user.characterName.trim().length > 0;
|
||||
},
|
||||
hasUserChanges() {
|
||||
if (!this.editableUser || !this.originalUser) return false;
|
||||
return this.editableUser.falukantData[0].money != this.originalUser.falukantData[0].money
|
||||
|| this.editableUser.falukantData[0].character.title_of_nobility != this.originalUser.falukantData[0].character.title_of_nobility
|
||||
|| this.originalAge != this.age;
|
||||
},
|
||||
availableStockTypes() {
|
||||
if (!this.newStock.branchId || !this.stockTypes.length) {
|
||||
return this.stockTypes;
|
||||
@@ -191,6 +201,10 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async searchUser() {
|
||||
if (!this.canSearch) {
|
||||
showError(this, 'Bitte Benutzername oder Charaktername eingeben.');
|
||||
return;
|
||||
}
|
||||
const userResult = await apiClient.post('/api/admin/falukant/searchuser', {
|
||||
userName: this.user.username,
|
||||
characterName: this.user.characterName
|
||||
@@ -221,9 +235,9 @@ export default {
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/api/admin/falukant/edituser`, dataToChange);
|
||||
this.$root.$refs.messageDialog.open('tr:admin.falukant.edituser.success');
|
||||
showSuccess(this, 'tr:admin.falukant.edituser.success');
|
||||
} catch (error) {
|
||||
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.error');
|
||||
showApiError(this, error, 'tr:admin.falukant.edituser.error');
|
||||
}
|
||||
},
|
||||
async deleteUser() {
|
||||
@@ -245,7 +259,7 @@ export default {
|
||||
this.userBranches = branchesResult.data;
|
||||
} catch (error) {
|
||||
console.error('Error loading user branches:', error);
|
||||
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.errorLoadingBranches');
|
||||
showApiError(this, error, 'tr:admin.falukant.edituser.errorLoadingBranches');
|
||||
} finally {
|
||||
this.loading.branches = false;
|
||||
}
|
||||
@@ -255,7 +269,7 @@ export default {
|
||||
await apiClient.put(`/api/admin/falukant/stock/${stock.id}`, {
|
||||
quantity: stock.quantity
|
||||
});
|
||||
this.$root.$refs.messageDialog.open('tr:admin.falukant.edituser.stockUpdated');
|
||||
showSuccess(this, 'tr:admin.falukant.edituser.stockUpdated');
|
||||
} catch (error) {
|
||||
console.error('Error updating stock:', error);
|
||||
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.errorUpdatingStock');
|
||||
|
||||
@@ -1,29 +1,127 @@
|
||||
<template>
|
||||
<div class="blog-list">
|
||||
<section class="blog-list__hero surface-card">
|
||||
<div>
|
||||
<span class="blog-list__kicker">Community-Blogs</span>
|
||||
<h1>Blogs</h1>
|
||||
<p>Artikel, Projektstaende und persoenliche Einblicke aus der YourPart-Community.</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link>
|
||||
</div>
|
||||
<div v-if="loading">Laden…</div>
|
||||
<div v-else>
|
||||
<div v-if="!blogs.length">Keine Blogs gefunden.</div>
|
||||
<ul>
|
||||
<li v-for="b in blogs" :key="b.id">
|
||||
<router-link :to="`/blogs/${b.id}`">{{ b.title }}</router-link>
|
||||
<small> – {{ b.owner?.username }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="blog-list__state surface-card">Laden…</div>
|
||||
<div v-else-if="!blogs.length" class="blog-list__state surface-card">Keine Blogs gefunden.</div>
|
||||
<div v-else class="blog-grid">
|
||||
<article v-for="b in blogs" :key="b.id" class="blog-card surface-card">
|
||||
<div class="blog-card__meta">von {{ b.owner?.username || 'Unbekannt' }}</div>
|
||||
<h2><router-link :to="blogUrl(b)">{{ b.title }}</router-link></h2>
|
||||
<p>{{ blogExcerpt(b) }}</p>
|
||||
<router-link class="blog-card__link" :to="blogUrl(b)">Zum Blog</router-link>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listBlogs } from '@/api/blogApi.js';
|
||||
import { createBlogSlug } from '@/utils/seo.js';
|
||||
export default {
|
||||
name: 'BlogListView',
|
||||
data: () => ({ blogs: [], loading: true }),
|
||||
async mounted() {
|
||||
try { this.blogs = await listBlogs(); } finally { this.loading = false; }
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
blogUrl(blog) {
|
||||
const slug = createBlogSlug(blog?.owner?.username, blog?.title);
|
||||
return slug ? `/blogs/${encodeURIComponent(slug)}` : `/blogs/${blog.id}`;
|
||||
},
|
||||
blogExcerpt(blog) {
|
||||
const source = blog?.description || 'Oeffentliche Eintraege, Gedanken und Projektstaende aus der Community.';
|
||||
return source.length > 150 ? `${source.slice(0, 147)}...` : source;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.blog-list {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.blog-list__hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.blog-list__kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.blog-list__hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blog-list__state {
|
||||
padding: 26px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.blog-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.blog-card__meta {
|
||||
margin-bottom: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.blog-card h2 {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.blog-card p {
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.blog-card__link {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.blog-list__hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<template>
|
||||
<div class="blog-view">
|
||||
<div v-if="loading">Laden…</div>
|
||||
<div v-else>
|
||||
<h1>{{ blog.title }}</h1>
|
||||
<p v-if="blog.description">{{ blog.description }}</p>
|
||||
<div v-if="loading" class="blog-view__state surface-card">Laden…</div>
|
||||
<div v-else-if="blog" class="blog-layout">
|
||||
<section class="blog-hero surface-card">
|
||||
<div>
|
||||
<div class="meta">von {{ blog.owner?.username }}</div>
|
||||
<h1>{{ blog.title }}</h1>
|
||||
<p v-if="blog.description" class="blog-description">{{ blog.description }}</p>
|
||||
</div>
|
||||
<div v-if="$store.getters.isLoggedIn" class="actions">
|
||||
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
|
||||
</div>
|
||||
<div class="posts">
|
||||
</section>
|
||||
<div class="blog-content">
|
||||
<section class="posts surface-card">
|
||||
<div class="posts__header">
|
||||
<h2>{{ $t('blog.posts') }}</h2>
|
||||
<div v-if="!items.length">{{ $t('blog.noPosts') }}</div>
|
||||
<span class="posts__count">{{ total }} Eintraege</span>
|
||||
</div>
|
||||
<div v-if="!items.length" class="blog-view__state">Keine Eintraege vorhanden.</div>
|
||||
<article v-for="p in items" :key="p.id" class="post">
|
||||
<h3>{{ p.title }}</h3>
|
||||
<div class="content" v-html="sanitize(p.content)" />
|
||||
@@ -20,8 +28,8 @@
|
||||
<span>{{ page }} / {{ pages }}</span>
|
||||
<button :disabled="page===pages" @click="go(page+1)">»</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isOwner" class="post-editor">
|
||||
</section>
|
||||
<div v-if="isOwner" class="post-editor surface-card">
|
||||
<h3>{{ $t('blog.newPost') }}</h3>
|
||||
<form @submit.prevent="addPost">
|
||||
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
|
||||
@@ -31,12 +39,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getBlog, listPosts, createPost } from '@/api/blogApi.js';
|
||||
import DOMPurify from 'dompurify';
|
||||
import RichTextEditor from './components/RichTextEditor.vue';
|
||||
import { applySeo, buildAbsoluteUrl, createBlogSlug, stripHtml, truncateText } from '@/utils/seo.js';
|
||||
export default {
|
||||
name: 'BlogView',
|
||||
props: { id: String, slug: String },
|
||||
@@ -46,9 +56,73 @@ export default {
|
||||
isOwner() {
|
||||
const u = this.$store.getters.user;
|
||||
return !!(u && this.blog && this.blog.owner && this.blog.owner.hashedId === u.id);
|
||||
}
|
||||
},
|
||||
pages() {
|
||||
return Math.max(1, Math.ceil(this.total / this.pageSize));
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadBlog();
|
||||
},
|
||||
watch: {
|
||||
'$route.fullPath': {
|
||||
async handler() {
|
||||
await this.loadBlog();
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
canonicalBlogPath() {
|
||||
const slug = createBlogSlug(this.blog?.owner?.username, this.blog?.title);
|
||||
if (slug) {
|
||||
return `/blogs/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
return this.blog?.id ? `/blogs/${this.blog.id}` : '/blogs';
|
||||
},
|
||||
applyBlogSeo() {
|
||||
if (!this.blog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plainTextPosts = this.items
|
||||
.map((item) => `${item.title || ''} ${stripHtml(item.content || '')}`.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const summarySource = this.blog.description || plainTextPosts || 'Oeffentlicher Community-Blog auf YourPart.';
|
||||
const description = truncateText(summarySource, 160);
|
||||
const canonicalPath = this.canonicalBlogPath();
|
||||
|
||||
applySeo({
|
||||
title: `${this.blog.title} | Blog auf YourPart`,
|
||||
description,
|
||||
canonicalPath,
|
||||
keywords: `Blog, ${this.blog.title}, ${this.blog.owner?.username || 'YourPart'}, Community`,
|
||||
type: 'article',
|
||||
jsonLd: [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Blog',
|
||||
name: this.blog.title,
|
||||
description,
|
||||
url: buildAbsoluteUrl(canonicalPath),
|
||||
inLanguage: 'de',
|
||||
author: this.blog.owner?.username
|
||||
? {
|
||||
'@type': 'Person',
|
||||
name: this.blog.owner.username,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
async loadBlog() {
|
||||
this.loading = true;
|
||||
this.blog = null;
|
||||
this.items = [];
|
||||
this.resolvedId = null;
|
||||
|
||||
try {
|
||||
let id = this.$route.params.id;
|
||||
// If we have a slug route param or the id is non-numeric, resolve to id
|
||||
@@ -67,12 +141,18 @@ export default {
|
||||
const useId = id || this.resolvedId;
|
||||
this.blog = await getBlog(useId);
|
||||
await this.fetchPage(1);
|
||||
this.applyBlogSeo();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
// this.$router.replace('/blogs');
|
||||
applySeo({
|
||||
title: 'Blog nicht gefunden | YourPart',
|
||||
description: 'Der angeforderte Blog konnte nicht geladen werden.',
|
||||
canonicalPath: '/blogs',
|
||||
robots: 'noindex, nofollow',
|
||||
});
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
methods: {
|
||||
sanitize(html) {
|
||||
return DOMPurify.sanitize(html || '');
|
||||
},
|
||||
@@ -83,8 +163,8 @@ export default {
|
||||
this.page = res.page;
|
||||
this.pageSize = res.pageSize;
|
||||
this.total = res.total;
|
||||
this.applyBlogSeo();
|
||||
},
|
||||
get pages() { return Math.max(1, Math.ceil(this.total / this.pageSize)); },
|
||||
async go(p) { if (p>=1 && p<=this.pages) await this.fetchPage(p); },
|
||||
async addPost() {
|
||||
if (!this.newPost.title || !this.newPost.content) return;
|
||||
@@ -98,12 +178,109 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.blog-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.blog-layout {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.blog-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
.blog-description {
|
||||
margin: 0;
|
||||
max-width: 70ch;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-bottom: 10px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.blog-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(280px, 0.95fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.posts,
|
||||
.post-editor {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.posts__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.posts__count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.post + .post {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.content {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.blog-view__state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.editbutton {
|
||||
border: 1px solid #000;
|
||||
background-color: #f9a22c;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 3px;
|
||||
padding: 0.2em 0.5em;
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.blog-hero,
|
||||
.blog-content {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.blog-hero {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.posts,
|
||||
.post-editor {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,14 @@
|
||||
<div class="contenthidden">
|
||||
<StatusBar ref="statusBar" />
|
||||
<div class="contentscroll">
|
||||
<div class="falukant-branch">
|
||||
<section class="branch-hero surface-card">
|
||||
<div>
|
||||
<span class="branch-kicker">Niederlassung</span>
|
||||
<h2>{{ $t('falukant.branch.title') }}</h2>
|
||||
<p>Produktion, Lager, Verkauf und Transport in einer spielweltbezogenen Steuerflaeche.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BranchSelection
|
||||
:branches="branches"
|
||||
@@ -310,6 +317,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -1110,8 +1118,49 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
h2 {
|
||||
padding-top: 20px;
|
||||
.falukant-branch {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.branch-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
|
||||
}
|
||||
|
||||
.branch-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.branch-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.branch-tab-content {
|
||||
margin-top: 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 252, 247, 0.86);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.branch-tab-pane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.send-all-vehicles {
|
||||
@@ -1161,11 +1210,12 @@ h2 {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
background: rgba(255,255,255,0.98);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
box-shadow: var(--shadow-medium);
|
||||
}
|
||||
|
||||
.send-vehicle-form {
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
<StatusBar />
|
||||
<div class="contentscroll family-layout">
|
||||
<div class="family-content">
|
||||
<section class="family-hero surface-card">
|
||||
<div>
|
||||
<span class="family-kicker">Familie</span>
|
||||
<h2>{{ $t('falukant.family.title') }}</h2>
|
||||
<p>Beziehungen, Kinder und familiäre Entwicklung in einer eigenen Spielweltansicht.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="spouse-section">
|
||||
<h3>{{ $t('falukant.family.spouse.title') }}</h3>
|
||||
@@ -36,7 +42,7 @@
|
||||
<td>
|
||||
<div class="progress">
|
||||
<div class="progress-inner" :style="{
|
||||
width: relationships[0].progress + '%',
|
||||
width: normalizeWooingProgress(relationships[0].progress) + '%',
|
||||
backgroundColor: progressColor(relationships[0].progress)
|
||||
}"></div>
|
||||
</div>
|
||||
@@ -200,6 +206,8 @@ import Character3D from '@/components/Character3D.vue'
|
||||
import apiClient from '@/utils/axios.js'
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
const WOOING_PROGRESS_TARGET = 70
|
||||
|
||||
export default {
|
||||
name: 'FamilyView',
|
||||
components: {
|
||||
@@ -342,6 +350,8 @@ export default {
|
||||
},
|
||||
|
||||
async cancelWooing() {
|
||||
const confirmed = window.confirm(this.$t('falukant.family.spouse.wooing.cancelConfirm'));
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await apiClient.post('/api/falukant/family/cancel-wooing');
|
||||
await this.loadFamilyData();
|
||||
@@ -409,11 +419,16 @@ export default {
|
||||
},
|
||||
|
||||
progressColor(p) {
|
||||
const pct = Math.max(0, Math.min(100, p)) / 100;
|
||||
const pct = this.normalizeWooingProgress(p) / 100;
|
||||
const red = Math.round(255 * (1 - pct));
|
||||
const green = Math.round(255 * pct);
|
||||
return `rgb(${red}, ${green}, 0)`;
|
||||
},
|
||||
normalizeWooingProgress(p) {
|
||||
const raw = Number(p) || 0
|
||||
const normalized = (raw / WOOING_PROGRESS_TARGET) * 100
|
||||
return Math.max(0, Math.min(100, normalized))
|
||||
},
|
||||
|
||||
jumpToPartyForm() {
|
||||
this.$router.push({
|
||||
@@ -469,7 +484,33 @@ export default {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
padding-top: 24px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.family-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
|
||||
}
|
||||
|
||||
.family-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.family-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.self-character-3d {
|
||||
@@ -483,15 +524,20 @@ export default {
|
||||
|
||||
.family-content {
|
||||
flex: 1;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.spouse-section,
|
||||
.children-section,
|
||||
.lovers-section {
|
||||
border: 1px solid #ccc;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
margin: 12px 0;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
background: rgba(255, 252, 247, 0.86);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.relationship-container {
|
||||
@@ -513,8 +559,8 @@ export default {
|
||||
.partner-character-3d {
|
||||
width: 200px;
|
||||
height: 280px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: #fdf1db;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -537,8 +583,8 @@ export default {
|
||||
.child-character-3d {
|
||||
width: 200px;
|
||||
height: 280px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: #fdf1db;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -597,10 +643,6 @@ export default {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.relationship>table,
|
||||
.relationship>ul {
|
||||
display: inline-block;
|
||||
@@ -648,4 +690,11 @@ h2 {
|
||||
.set-heir-button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.relationship-row,
|
||||
.children-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="falukant-overview">
|
||||
<StatusBar />
|
||||
<section class="falukant-hero surface-card">
|
||||
<div>
|
||||
<span class="falukant-kicker">Falukant</span>
|
||||
<h2>{{ $t('falukant.overview.title') }}</h2>
|
||||
<p>Dein Stand in Wirtschaft, Familie und Besitz in einer verdichteten Uebersicht.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="falukantUser?.character" class="falukant-summary-grid">
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Niederlassungen</span>
|
||||
<strong>{{ branchCount }}</strong>
|
||||
<p>Direkter Zugriff auf deine wichtigsten Geschaeftsstandorte.</p>
|
||||
</article>
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Produktionen aktiv</span>
|
||||
<strong>{{ productionCount }}</strong>
|
||||
<p>Laufende Produktionen, die zeitnah Abschluss oder Kontrolle brauchen.</p>
|
||||
</article>
|
||||
<article class="summary-card surface-card">
|
||||
<span class="summary-card__label">Lagerpositionen</span>
|
||||
<strong>{{ stockEntryCount }}</strong>
|
||||
<p>Verdichteter Blick auf Warenbestand ueber alle Regionen.</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="falukantUser?.character" class="falukant-routine-grid">
|
||||
<article
|
||||
v-for="action in routineActions"
|
||||
:key="action.title"
|
||||
class="routine-card surface-card"
|
||||
>
|
||||
<span class="routine-card__eyebrow">{{ action.kicker }}</span>
|
||||
<h3>{{ action.title }}</h3>
|
||||
<p>{{ action.description }}</p>
|
||||
<button type="button" :class="action.secondary ? 'button-secondary' : ''" @click="openRoute(action.route)">
|
||||
{{ action.cta }}
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Erben-Auswahl wenn kein Charakter vorhanden -->
|
||||
<div v-if="!falukantUser?.character" class="heir-selection-container">
|
||||
@@ -16,7 +55,7 @@
|
||||
<div class="heir-info">
|
||||
<div class="heir-name">
|
||||
{{ $t(`falukant.titles.${heir.gender}.noncivil`) }}
|
||||
{{ heir.definedFirstName.name }} {{ heir.definedLastName.name }}
|
||||
{{ heir.definedFirstName?.name || '---' }} {{ heir.definedLastName?.name || '' }}
|
||||
</div>
|
||||
<div class="heir-age">{{ $t('falukant.overview.metadata.age') }}: {{ heir.age }}</div>
|
||||
</div>
|
||||
@@ -136,6 +175,7 @@
|
||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||
import Character3D from '@/components/Character3D.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { showError, showSuccess } from '@/utils/feedback.js';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
const AVATAR_POSITIONS = {
|
||||
@@ -233,6 +273,50 @@ export default {
|
||||
const m = this.falukantUser?.money;
|
||||
return typeof m === 'string' ? parseFloat(m) : m;
|
||||
},
|
||||
branchCount() {
|
||||
return this.falukantUser?.branches?.length || 0;
|
||||
},
|
||||
productionCount() {
|
||||
return this.productions.length;
|
||||
},
|
||||
stockEntryCount() {
|
||||
return this.allStock.length;
|
||||
},
|
||||
routineActions() {
|
||||
return [
|
||||
{
|
||||
kicker: 'Routine',
|
||||
title: 'Niederlassung oeffnen',
|
||||
description: 'Die schnellste Route zu Produktion, Lager, Verkauf und Transport.',
|
||||
cta: 'Zu den Betrieben',
|
||||
route: 'BranchView',
|
||||
},
|
||||
{
|
||||
kicker: 'Ueberblick',
|
||||
title: 'Finanzen pruefen',
|
||||
description: 'Kontostand, Verlauf und wirtschaftliche Entwicklung ohne lange Suche.',
|
||||
cta: 'Geldhistorie',
|
||||
route: 'MoneyHistoryView',
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
kicker: 'Charakter',
|
||||
title: 'Familie und Nachfolge',
|
||||
description: 'Wichtige persoenliche Entscheidungen und Haushaltsstatus gesammelt.',
|
||||
cta: 'Familie oeffnen',
|
||||
route: 'FalukantFamily',
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
kicker: 'Besitz',
|
||||
title: 'Haus und Umfeld',
|
||||
description: 'Wohnsitz und alltaeglicher Status als eigener Arbeitsbereich.',
|
||||
cta: 'Zum Haus',
|
||||
route: 'HouseView',
|
||||
secondary: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
locale() {
|
||||
return window.navigator.language || 'en-US';
|
||||
},
|
||||
@@ -369,6 +453,16 @@ export default {
|
||||
openBranch(branchId) {
|
||||
this.$router.push({ name: 'BranchView', params: { branchId } });
|
||||
},
|
||||
openRoute(routeName) {
|
||||
if (routeName === 'BranchView') {
|
||||
const firstBranch = this.falukantUser?.branches?.[0];
|
||||
if (firstBranch?.id) {
|
||||
this.openBranch(firstBranch.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.$router.push({ name: routeName });
|
||||
},
|
||||
async fetchProductions() {
|
||||
try {
|
||||
const response = await apiClient.get('/api/falukant/productions');
|
||||
@@ -381,13 +475,6 @@ export default {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
},
|
||||
async fetchPotentialHeirs() {
|
||||
// Prüfe sowohl mainBranchRegion.id als auch mainBranchRegionId
|
||||
const regionId = this.falukantUser?.mainBranchRegion?.id || this.falukantUser?.mainBranchRegionId;
|
||||
if (!regionId) {
|
||||
console.error('No main branch region found', this.falukantUser);
|
||||
this.potentialHeirs = [];
|
||||
return;
|
||||
}
|
||||
this.loadingHeirs = true;
|
||||
try {
|
||||
const response = await apiClient.get('/api/falukant/heirs/potential');
|
||||
@@ -406,15 +493,15 @@ export default {
|
||||
async selectHeir(heirId) {
|
||||
try {
|
||||
await apiClient.post('/api/falukant/heirs/select', { heirId });
|
||||
// Lade User-Daten neu
|
||||
await this.fetchFalukantUser();
|
||||
if (this.falukantUser?.character) {
|
||||
await this.fetchAllStock();
|
||||
await this.fetchProductions();
|
||||
}
|
||||
showSuccess(this, 'Erbe wurde uebernommen.');
|
||||
} catch (error) {
|
||||
console.error('Error selecting heir:', error);
|
||||
alert(this.$t('falukant.overview.heirSelection.error'));
|
||||
showError(this, this.$t('falukant.overview.heirSelection.error'));
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -422,16 +509,99 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.falukant-overview {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.falukant-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
|
||||
}
|
||||
|
||||
.falukant-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.falukant-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.falukant-summary-grid,
|
||||
.falukant-routine-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.falukant-routine-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.routine-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.summary-card strong {
|
||||
display: block;
|
||||
margin: 6px 0 8px;
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summary-card p,
|
||||
.routine-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.summary-card__label,
|
||||
.routine-card__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.routine-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.routine-card button {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.overviewcontainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||
grid-gap: 5px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overviewcontainer>div {
|
||||
border: 1px solid #ccc;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 253, 249, 0.82);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.imagecontainer {
|
||||
@@ -445,10 +615,12 @@ export default {
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: rgba(255,255,255,0.72);
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: crisp-edges;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.house-with-character {
|
||||
@@ -460,8 +632,8 @@ export default {
|
||||
.house {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background-repeat: no-repeat;
|
||||
image-rendering: crisp-edges;
|
||||
z-index: 1;
|
||||
@@ -477,16 +649,13 @@ export default {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
h2 {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.heir-selection-container {
|
||||
border: 2px solid #dc3545;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(177, 59, 53, 0.18);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background-color: #fff3cd;
|
||||
background-color: rgba(255, 243, 205, 0.92);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.heir-selection-container h3 {
|
||||
@@ -502,10 +671,10 @@ h2 {
|
||||
}
|
||||
|
||||
.heir-card {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 15px;
|
||||
background-color: white;
|
||||
background-color: rgba(255,255,255,0.86);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -525,6 +694,20 @@ h2 {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.falukant-routine-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.falukant-summary-grid,
|
||||
.falukant-routine-grid,
|
||||
.overviewcontainer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.select-heir-button {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
@@ -542,6 +725,16 @@ h2 {
|
||||
.loading, .no-heirs {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.overviewcontainer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.imagecontainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
<template>
|
||||
<div class="home-logged-in">
|
||||
<header class="dashboard-header">
|
||||
<section class="dashboard-hero surface-card">
|
||||
<div class="dashboard-hero__copy">
|
||||
<span class="dashboard-kicker">Dein Bereich</span>
|
||||
<h1>Willkommen zurück!</h1>
|
||||
<p class="dashboard-subtitle">Schön, dass du wieder da bist.</p>
|
||||
<div class="dashboard-toolbar">
|
||||
<p class="dashboard-subtitle">
|
||||
Dein persönlicher Einstieg in Community, Termine, Falukant und laufende Aktivitäten.
|
||||
</p>
|
||||
</div>
|
||||
<div class="dashboard-toolbar surface-card">
|
||||
<button
|
||||
v-if="!editMode"
|
||||
type="button"
|
||||
@@ -42,7 +47,25 @@
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-overview">
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">Aktive Widgets</span>
|
||||
<strong>{{ widgets.length }}</strong>
|
||||
<p>Dein Dashboard ist modular aufgebaut und kann jederzeit umsortiert werden.</p>
|
||||
</article>
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">Verfügbare Module</span>
|
||||
<strong>{{ widgetTypeOptions.length }}</strong>
|
||||
<p>Du kannst Community-, Kalender-, News- und Falukant-Module kombinieren.</p>
|
||||
</article>
|
||||
<article class="overview-card surface-card">
|
||||
<span class="overview-card__label">Bearbeitungsmodus</span>
|
||||
<strong>{{ editMode ? 'Aktiv' : 'Aus' }}</strong>
|
||||
<p>{{ editMode ? 'Widgets können gerade ergänzt und angepasst werden.' : 'Inhalte bleiben fokussiert und ruhig lesbar.' }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="loadError"
|
||||
@@ -58,6 +81,15 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="dashboard-shell"
|
||||
>
|
||||
<div class="dashboard-shell__header">
|
||||
<div>
|
||||
<h2>Deine Übersicht</h2>
|
||||
<p>Widgets lassen sich verschieben und im Bearbeitungsmodus anpassen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="dashboardGridRef"
|
||||
class="dashboard-grid"
|
||||
@dragover.prevent
|
||||
@@ -104,6 +136,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="widgets.length === 0 && !loading" class="dashboard-empty">
|
||||
<p>Noch keine Widgets. Klicke auf „Dashboard bearbeiten“ und dann „+ Widget hinzufügen“.</p>
|
||||
@@ -306,98 +339,170 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.home-logged-in {
|
||||
max-width: 1200px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
padding: 8px 0 24px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 24px;
|
||||
.dashboard-hero {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 26px;
|
||||
margin-bottom: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(248, 162, 43, 0.18), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(250, 243, 233, 0.98) 100%);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: #333;
|
||||
margin: 0 0 4px 0;
|
||||
.dashboard-hero__copy {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.dashboard-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.dashboard-hero h1 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: #666;
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
max-width: 58ch;
|
||||
}
|
||||
|
||||
.dashboard-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.overview-card__label {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.overview-card strong {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.overview-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
min-width: 300px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.btn-edit,
|
||||
.btn-done {
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--color-primary-orange);
|
||||
color: var(--color-text-on-orange);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.btn-edit:hover,
|
||||
.btn-done:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
color: #2b1f14;
|
||||
}
|
||||
|
||||
.widget-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-add-again {
|
||||
padding: 8px 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
background: #fff;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
min-height: 40px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-color: var(--color-border-strong);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-add-again:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
border-color: var(--color-primary-orange);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.widget-type-select {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-text-secondary);
|
||||
background: #fff;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.dashboard-message {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
background: rgba(177, 59, 53, 0.12);
|
||||
color: #7a241f;
|
||||
border: 1px solid rgba(177, 59, 53, 0.18);
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 252, 247, 0.94) 0%, rgba(248, 241, 231, 0.96) 100%);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.dashboard-shell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-shell__header h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.dashboard-shell__header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-auto-rows: 200px;
|
||||
gap: 20px;
|
||||
grid-auto-rows: 220px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dashboard-grid-cell {
|
||||
@@ -415,9 +520,9 @@ export default {
|
||||
}
|
||||
|
||||
.dashboard-grid-cell.drop-target {
|
||||
outline: 2px dashed #0d6efd;
|
||||
outline: 2px dashed rgba(248, 162, 43, 0.82);
|
||||
outline-offset: 4px;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.dashboard-grid-cell.drag-source {
|
||||
@@ -426,13 +531,14 @@ export default {
|
||||
|
||||
.dashboard-widget-edit {
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.widget-edit-fields {
|
||||
@@ -441,40 +547,58 @@ export default {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.widget-edit-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
align-self: flex-start;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
background: var(--color-primary-orange);
|
||||
color: var(--color-text-on-orange);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
min-height: 36px;
|
||||
background: rgba(177, 59, 53, 0.12);
|
||||
color: #7a241f;
|
||||
border-color: rgba(177, 59, 53, 0.18);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: var(--color-primary-orange-light);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-text-secondary);
|
||||
background: rgba(177, 59, 53, 0.18);
|
||||
}
|
||||
|
||||
.dashboard-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed #dee2e6;
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.home-logged-in {
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.dashboard-hero {
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-toolbar {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-shell {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,20 +8,24 @@
|
||||
<Character3D gender="male" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div>
|
||||
<section class="actions-panel actions-panel--story surface-card">
|
||||
<div class="panel-intro">
|
||||
<span class="panel-kicker">Dein Einstieg</span>
|
||||
<h2>{{ $t('home.nologin.welcome') }}</h2>
|
||||
<p>{{ $t('home.nologin.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="story-highlight">
|
||||
<p>
|
||||
YourPart ist eine wachsende Online‑Plattform, die Community‑Funktionen, Echtzeit‑Chat, Foren,
|
||||
ein soziales Netzwerk mit Bildergalerie sowie das Aufbauspiel <em>Falukant</em> vereint.
|
||||
Aktuell befindet sich die Seite in der Beta‑Phase – wir erweitern Funktionen, Inhalte und
|
||||
Stabilität
|
||||
kontinuierlich.
|
||||
YourPart verbindet Community, Echtzeit-Chat, Foren, Bildergalerie und das Aufbauspiel
|
||||
<em>Falukant</em> in einer Plattform. Der Fokus liegt auf Austausch, spielerischer Tiefe und
|
||||
einer wachsenden Produktwelt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="story-block">
|
||||
<h3>{{ $t('home.nologin.expected.title') }}</h3>
|
||||
<ul>
|
||||
<ul class="feature-list">
|
||||
<li v-html="$t('home.nologin.expected.items.chat')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.social')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.forum')"></li>
|
||||
@@ -29,45 +33,62 @@
|
||||
<li v-html="$t('home.nologin.expected.items.minigames')"></li>
|
||||
<li v-html="$t('home.nologin.expected.items.multilingual')"></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="story-columns">
|
||||
<article>
|
||||
<h3>{{ $t('home.nologin.falukantShort.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.falukantShort.text') }}</p>
|
||||
|
||||
</article>
|
||||
<article>
|
||||
<h3>{{ $t('home.nologin.privacyBeta.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.privacyBeta.text') }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="story-cta">
|
||||
<h3>{{ $t('home.nologin.getStarted.title') }}</h3>
|
||||
<p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
</section>
|
||||
<section class="actions-panel actions-panel--access surface-card">
|
||||
<div class="login-panel">
|
||||
<span class="panel-kicker">Direkt starten</span>
|
||||
<h2>{{ $t('home.nologin.login.submit') }}</h2>
|
||||
<div class="login-fields">
|
||||
<input v-model="username" size="20" type="text" :placeholder="$t('home.nologin.login.name')"
|
||||
:title="$t('home.nologin.login.namedescription')" @keydown.enter="focusPassword">
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="password" size="20" type="password"
|
||||
:placeholder="$t('home.nologin.login.password')"
|
||||
:title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin"
|
||||
ref="passwordInput">
|
||||
</div>
|
||||
<div>
|
||||
<label><input type="checkbox"><span>{{ $t('home.nologin.login.stayLoggedIn') }}</span></label>
|
||||
<div class="stay-logged-in-row">
|
||||
<label class="stay-logged-in-label">
|
||||
<input class="stay-logged-in-checkbox" type="checkbox">
|
||||
<span>{{ $t('home.nologin.login.stayLoggedIn') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="primary-action" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<h2>{{ $t('home.nologin.randomchat') }}</h2>
|
||||
<button @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<div class="access-split">
|
||||
<article class="access-card">
|
||||
<h3>{{ $t('home.nologin.randomchat') }}</h3>
|
||||
<p>Ohne lange Vorbereitung direkt in spontane Begegnungen und offene Gespraeche starten.</p>
|
||||
<button type="button" class="secondary-action" @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
|
||||
</article>
|
||||
<article class="access-card">
|
||||
<h3>Konto und Zugang</h3>
|
||||
<p>Neu hier oder Passwort vergessen? Von hier aus gelangst du direkt in Registrierung und Wiederherstellung.</p>
|
||||
<div class="access-links">
|
||||
<span @click="openPasswordResetDialog" class="link">{{
|
||||
$t('home.nologin.login.lostpassword') }}</span> | <span id="o1p5iry1"
|
||||
@click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
|
||||
$t('home.nologin.login.lostpassword') }}</span>
|
||||
<span @click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mascot">
|
||||
<Character3D gender="female" />
|
||||
@@ -125,7 +146,8 @@ export default {
|
||||
const response = await apiClient.post('/api/auth/login', { username: this.username, password: this.password });
|
||||
this.login(response.data);
|
||||
} catch (error) {
|
||||
this.$root.$refs.errorDialog.open(`tr:error.${error.response.data.error}`);
|
||||
const errorKey = error?.response?.data?.error || 'network';
|
||||
this.$root.$refs.errorDialog.open(`tr:error.${errorKey}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,58 +156,197 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.beta-banner {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeeba;
|
||||
color: #856404;
|
||||
width: min(100%, var(--content-max-width));
|
||||
background: linear-gradient(180deg, #fff2cf 0%, #fde7b2 100%);
|
||||
border: 1px solid rgba(201, 130, 31, 0.24);
|
||||
color: #8a5a12;
|
||||
padding: 10px 14px;
|
||||
margin: 0 0 12px 0;
|
||||
margin: 0 0 14px 0;
|
||||
text-align: center;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.home-structure {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
gap: 2em;
|
||||
gap: 1.4rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.home-structure>div {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mascot {
|
||||
flex: 0 0 clamp(180px, 22%, 280px);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #fdf1db;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
min-height: 400px;
|
||||
align-items: stretch;
|
||||
background: linear-gradient(180deg, #fff5e8 0%, #fce7ca 100%);
|
||||
border: 1px solid rgba(248, 162, 43, 0.16);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 10px 24px rgba(93, 64, 55, 0.08);
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
height: clamp(320px, 68vh, 560px);
|
||||
min-height: 320px;
|
||||
max-height: 560px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
gap: 1rem;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.actions>div {
|
||||
.actions-panel {
|
||||
flex: 1;
|
||||
background-color: #FFF4F0;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(248, 240, 231, 0.96) 100%);
|
||||
color: #5D4037;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 1.2rem 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.actions>div>h2 {
|
||||
color: var(--color-primary-orange);
|
||||
.actions-panel h2,
|
||||
.actions-panel h3 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.panel-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.7rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-intro,
|
||||
.story-highlight,
|
||||
.story-block,
|
||||
.story-columns,
|
||||
.story-cta,
|
||||
.login-panel,
|
||||
.access-split {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.story-highlight {
|
||||
padding: 1rem 1.1rem;
|
||||
margin: 0.8rem 0 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(248, 162, 43, 0.08);
|
||||
border: 1px solid rgba(248, 162, 43, 0.12);
|
||||
}
|
||||
|
||||
.story-block {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.feature-list li + li {
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.story-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.story-columns article,
|
||||
.story-cta,
|
||||
.access-card {
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-fields {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.primary-action,
|
||||
.secondary-action {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.access-split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.access-card p {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.access-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.stay-logged-in-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.stay-logged-in-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.stay-logged-in-checkbox {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 0 0 16px;
|
||||
accent-color: var(--color-primary-orange);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.seo-content {
|
||||
@@ -233,7 +394,37 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.home-structure {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mascot {
|
||||
min-height: 260px;
|
||||
height: 260px;
|
||||
flex: 0 0 260px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.actions-panel {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.story-columns,
|
||||
.access-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
<template>
|
||||
<div class="contenthidden">
|
||||
<div class="contentscroll">
|
||||
<div class="contentscroll match3-view">
|
||||
<!-- Spiel-Titel -->
|
||||
<div class="game-title">
|
||||
<section class="game-title surface-card">
|
||||
<span class="game-title__eyebrow">Minispiele</span>
|
||||
<h1>{{ $t('minigames.match3.title') }}</h1>
|
||||
<p>{{ $t('minigames.match3.campaignDescription') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="play-focus surface-card">
|
||||
<div class="play-focus__main">
|
||||
<span class="play-focus__eyebrow">Naechster Schritt</span>
|
||||
<h2>{{ playFocusTitle }}</h2>
|
||||
<p>{{ playFocusDescription }}</p>
|
||||
</div>
|
||||
<div class="play-focus__stats">
|
||||
<span class="play-focus__pill">Level {{ currentLevel }}</span>
|
||||
<span class="play-focus__pill">{{ completedObjectivesCount }}/{{ totalObjectivesCount || 0 }} Ziele</span>
|
||||
<span class="play-focus__pill">{{ safeMovesLeft }} Zuege uebrig</span>
|
||||
</div>
|
||||
<div class="play-focus__actions">
|
||||
<button class="btn btn-primary" @click="isPaused ? resumeGame() : pauseGame()">
|
||||
{{ isPaused ? $t('minigames.match3.resume') : $t('minigames.match3.pause') }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="toggleLevelDescription">
|
||||
{{ levelDescriptionExpanded ? 'Ziele einklappen' : 'Ziele anzeigen' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="restartLevel">
|
||||
{{ $t('minigames.match3.restartLevel') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Kampagnen-Status -->
|
||||
<div class="game-layout">
|
||||
@@ -45,13 +70,13 @@
|
||||
|
||||
<div class="game-content">
|
||||
<!-- Verbleibende Züge -->
|
||||
<div class="moves-left-display">
|
||||
<div class="moves-left-display surface-card">
|
||||
<span class="moves-left-label">{{ $t('minigames.match3.movesLeft') }}:</span>
|
||||
<span class="moves-left-value">{{ safeMovesLeft }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Level-Info -->
|
||||
<div class="level-info-card" v-if="currentLevelData">
|
||||
<div class="level-info-card surface-card" v-if="currentLevelData">
|
||||
<div class="level-header">
|
||||
<div class="level-header-content">
|
||||
<h3 class="level-title">
|
||||
@@ -6010,6 +6035,42 @@ export default {
|
||||
},
|
||||
safeMovesLeft() {
|
||||
return this.movesLeft || 0;
|
||||
},
|
||||
totalObjectivesCount() {
|
||||
return this.currentLevelData?.objectives?.length || 0;
|
||||
},
|
||||
completedObjectivesCount() {
|
||||
if (!this.currentLevelData?.objectives?.length) {
|
||||
return 0;
|
||||
}
|
||||
return this.currentLevelData.objectives.filter((objective) => objective.completed).length;
|
||||
},
|
||||
nextPendingObjective() {
|
||||
return this.currentLevelData?.objectives?.find((objective) => !objective.completed) || null;
|
||||
},
|
||||
playFocusTitle() {
|
||||
if (this.isPaused) {
|
||||
return 'Spiel ist pausiert';
|
||||
}
|
||||
if (!this.currentLevelData) {
|
||||
return 'Level wird vorbereitet';
|
||||
}
|
||||
if (this.nextPendingObjective) {
|
||||
return this.nextPendingObjective.description || 'Aktuelles Ziel abschliessen';
|
||||
}
|
||||
return 'Level sauber zu Ende spielen';
|
||||
},
|
||||
playFocusDescription() {
|
||||
if (this.isPaused) {
|
||||
return 'Setze das Level fort oder starte es kontrolliert neu, ohne den aktuellen Kontext zu verlieren.';
|
||||
}
|
||||
if (!this.currentLevelData) {
|
||||
return 'Sobald das Level geladen ist, erscheinen hier das naechste Ziel und die passende Hauptaktion.';
|
||||
}
|
||||
if (this.nextPendingObjective) {
|
||||
return `Konzentriere dich zuerst auf dieses Ziel. Bereits erledigt: ${this.completedObjectivesCount} von ${this.totalObjectivesCount}.`;
|
||||
}
|
||||
return 'Alle sichtbaren Ziele sind erledigt. Jetzt zaehlt nur noch der saubere Abschluss des Levels.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6019,26 +6080,94 @@ export default {
|
||||
/* Minimalistischer Style - nur für Match3Game */
|
||||
/* Verwendet globale Scroll-Klassen: .contenthidden und .contentscroll */
|
||||
|
||||
.match3-view {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-top: 20px;
|
||||
margin: 16px auto 30px;
|
||||
max-width: 980px;
|
||||
padding: 28px;
|
||||
background: linear-gradient(135deg, rgba(255, 247, 233, 0.98), rgba(245, 237, 225, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.game-title__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-title h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.game-title p {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.play-focus {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
max-width: 980px;
|
||||
margin: 0 auto 20px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.play-focus__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.play-focus h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.play-focus p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.play-focus__stats,
|
||||
.play-focus__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.play-focus__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: #8a5411;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Spiel-Layout */
|
||||
.game-layout {
|
||||
display: flex;
|
||||
@@ -6062,12 +6191,12 @@ export default {
|
||||
|
||||
/* Verbleibende Züge Anzeige */
|
||||
.moves-left-display {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: var(--shadow-soft);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6078,7 +6207,7 @@ export default {
|
||||
.moves-left-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.moves-left-value {
|
||||
@@ -6089,17 +6218,17 @@ export default {
|
||||
|
||||
/* Statistik-Bereich */
|
||||
.stats-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 10px;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
@@ -6107,7 +6236,7 @@ export default {
|
||||
}
|
||||
|
||||
.stats-header:hover {
|
||||
background-color: #f8f9fa;
|
||||
background-color: rgba(248, 162, 43, 0.08);
|
||||
}
|
||||
|
||||
.stats-header-content {
|
||||
@@ -6121,7 +6250,7 @@ export default {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -6174,7 +6303,7 @@ export default {
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Statistik-Werte Farben */
|
||||
@@ -6185,12 +6314,12 @@ export default {
|
||||
|
||||
/* Level-Info */
|
||||
.level-info-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 5px;
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: var(--shadow-soft);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
@@ -6198,7 +6327,7 @@ export default {
|
||||
.level-header {
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 10px;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s ease;
|
||||
@@ -6206,7 +6335,7 @@ export default {
|
||||
}
|
||||
|
||||
.level-header:hover {
|
||||
background-color: #f8f9fa;
|
||||
background-color: rgba(248, 162, 43, 0.08);
|
||||
}
|
||||
|
||||
.level-header-content {
|
||||
@@ -6220,7 +6349,7 @@ export default {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: var(--color-text-primary);
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
pointer-events: none;
|
||||
@@ -6233,7 +6362,7 @@ export default {
|
||||
.level-info-card p {
|
||||
margin: 0 0 15px 0;
|
||||
text-align: left;
|
||||
color: #666;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -6248,8 +6377,8 @@ export default {
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(255, 255, 255, 0.64);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.objective-icon {
|
||||
@@ -6265,7 +6394,7 @@ export default {
|
||||
.objective-progress {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -6273,10 +6402,10 @@ export default {
|
||||
.game-board-container {
|
||||
display: inline-block;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
background: rgba(255, 251, 246, 0.96);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-soft);
|
||||
margin-bottom: 20px;
|
||||
position: relative; /* Für absolute Positionierung der Animationen */
|
||||
}
|
||||
@@ -6461,6 +6590,17 @@ export default {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.game-title {
|
||||
margin-top: 12px;
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.play-focus {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
@@ -6875,4 +7015,3 @@ export default {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<section class="calendar-hero surface-card">
|
||||
<div>
|
||||
<span class="calendar-kicker">Planung</span>
|
||||
<h2>{{ $t('personal.calendar.title') }}</h2>
|
||||
<p>Termine, Geburtstage und eigene Eintraege in einer strukturierten Uebersicht.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="calendar-toolbar">
|
||||
<div class="calendar-toolbar surface-card">
|
||||
<div class="nav-buttons">
|
||||
<button @click="openNewEventDialog()" class="btn-new-event">
|
||||
+ {{ $t('personal.calendar.newEntry') }}
|
||||
@@ -27,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Selection info -->
|
||||
<div v-if="selectedDates.length > 1" class="selection-info">
|
||||
<div v-if="selectedDates.length > 1" class="selection-info surface-card">
|
||||
{{ $t('personal.calendar.selectedDays', { count: selectedDates.length }) }}
|
||||
<button @click="createEventFromSelection" class="btn-create-from-selection">
|
||||
{{ $t('personal.calendar.createEventForSelection') }}
|
||||
@@ -839,16 +845,39 @@ export default {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.calendar-view {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
padding: 0 0 24px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
.calendar-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.calendar-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.calendar-hero h2 {
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.calendar-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
// Toolbar
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
@@ -857,6 +886,7 @@ h2 {
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
@@ -1471,7 +1501,7 @@ h2 {
|
||||
.category-btn {
|
||||
padding: 6px 12px;
|
||||
border: 2px solid;
|
||||
border-radius: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
|
||||
100
frontend/src/views/public/FalukantLandingView.vue
Normal file
100
frontend/src/views/public/FalukantLandingView.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<section class="marketing-page">
|
||||
<div class="hero">
|
||||
<p class="eyebrow">Browser-Aufbauspiel</p>
|
||||
<h1>Falukant verbindet Wirtschaft, Politik und Charakterentwicklung in einer mittelalterlichen Spielwelt.</h1>
|
||||
<p class="lead">
|
||||
Baue Besitz auf, verwalte Zweigstellen, pflege Beziehungen und triff politische Entscheidungen in einem
|
||||
persistenten Browsergame innerhalb von YourPart.
|
||||
</p>
|
||||
<router-link class="cta" to="/">Jetzt entdecken</router-link>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<article>
|
||||
<h2>Wirtschaft mit Tiefe</h2>
|
||||
<p>Produktion, Lager, Handel und Finanzen greifen ineinander und erzeugen eine langfristige Aufbauspiel-Dynamik.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Persoenliche Entwicklung</h2>
|
||||
<p>Familie, Bildung, Gesundheit und gesellschaftlicher Status beeinflussen deinen Weg in Falukant.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Politik und Unterwelt</h2>
|
||||
<p>Zwischen Kirche, Reputation, Adel und dunklen Netzwerken entstehen Entscheidungen mit spuerbaren Folgen.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marketing-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px 72px;
|
||||
color: #40261a;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 32px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, #f7e0bb 0%, #f6c27d 45%, #e8924d 100%);
|
||||
box-shadow: 0 20px 60px rgba(106, 56, 20, 0.18);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 760px;
|
||||
margin: 20px 0 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
background: #40261a;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.grid article {
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: #fff7ef;
|
||||
border: 1px solid rgba(64, 38, 26, 0.08);
|
||||
}
|
||||
|
||||
.grid h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.grid p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
</style>
|
||||
98
frontend/src/views/public/MinigamesLandingView.vue
Normal file
98
frontend/src/views/public/MinigamesLandingView.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<section class="marketing-page">
|
||||
<div class="hero">
|
||||
<p class="eyebrow">Browser-Minispiele</p>
|
||||
<h1>Kurze Spielrunden, klare Ziele und direkte Action mit Match 3 und Taxi.</h1>
|
||||
<p class="lead">
|
||||
Die Minispiele auf YourPart liefern schnelle Abwechslung direkt im Browser und erweitern die Plattform um
|
||||
spielbare Casual-Formate.
|
||||
</p>
|
||||
<router-link class="cta" to="/">Zur Startseite</router-link>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<article>
|
||||
<h2>Match 3</h2>
|
||||
<p>Das klassische Puzzle-Prinzip mit Kampagnenstruktur fuer Spielerinnen und Spieler, die kurze Sessions lieben.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Taxi</h2>
|
||||
<p>Fahre Passagiere effizient ans Ziel und verbessere deine Kontrolle, Streckenwahl und Reaktionsfaehigkeit.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marketing-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px 72px;
|
||||
color: #17323a;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 32px;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
|
||||
linear-gradient(135deg, #d4f0e6 0%, #7dd0be 40%, #2e8b83 100%);
|
||||
box-shadow: 0 20px 60px rgba(13, 84, 93, 0.18);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 4vw, 3.5rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 760px;
|
||||
margin: 20px 0 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
background: #17323a;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.cards article {
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: #effaf6;
|
||||
border: 1px solid rgba(23, 50, 58, 0.08);
|
||||
}
|
||||
|
||||
.cards h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cards p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
</style>
|
||||
102
frontend/src/views/public/VocabLandingView.vue
Normal file
102
frontend/src/views/public/VocabLandingView.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<section class="marketing-page">
|
||||
<div class="hero">
|
||||
<p class="eyebrow">Sprachen online lernen</p>
|
||||
<h1>Der Vokabeltrainer auf YourPart kombiniert Lernen, Kurse und Uebungen in einer Plattform.</h1>
|
||||
<p class="lead">
|
||||
Arbeite mit interaktiven Lektionen, erweitere deinen Wortschatz und nutze strukturierte Inhalte fuer einen
|
||||
motivierenden Lernfluss direkt im Browser.
|
||||
</p>
|
||||
<router-link class="cta" to="/">Kostenlos starten</router-link>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<article>
|
||||
<h2>Interaktive Kurse</h2>
|
||||
<p>Kurse, Lektionen und Uebungen helfen beim systematischen Aufbau neuer Sprachkenntnisse.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Praxisorientiert</h2>
|
||||
<p>Wortschatz, Grammatik und Wiederholung werden auf eine alltagstaugliche Lernroutine ausgerichtet.</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Teil einer Community</h2>
|
||||
<p>Der Sprachbereich ist in eine groessere Community-Plattform mit Blogs, Forum und Chat eingebettet.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marketing-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px 72px;
|
||||
color: #1f2f1d;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 32px;
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at right top, rgba(255, 255, 255, 0.78), transparent 30%),
|
||||
linear-gradient(135deg, #eef6c8 0%, #bddd74 45%, #6b9d34 100%);
|
||||
box-shadow: 0 20px 60px rgba(60, 87, 28, 0.18);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 4vw, 3.5rem);
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 760px;
|
||||
margin: 20px 0 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-block;
|
||||
margin-top: 24px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
background: #1f2f1d;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.features article {
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: #f7fbe9;
|
||||
border: 1px solid rgba(31, 47, 29, 0.08);
|
||||
}
|
||||
|
||||
.features h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.features p {
|
||||
margin: 0;
|
||||
line-height: 1.65;
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="account-settings">
|
||||
<section class="account-settings__hero surface-card">
|
||||
<span class="account-settings__eyebrow">Einstellungen</span>
|
||||
<h2>{{ $t("settings.account.title") }}</h2>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.username") }} </span><input type="text" v-model="username"
|
||||
:placeholder="$t('settings.account.username')" /></label>
|
||||
<p>Benutzername, E-Mail, Passwort und Sichtbarkeit an einer Stelle pflegen.</p>
|
||||
</section>
|
||||
|
||||
<section class="account-settings__panel surface-card">
|
||||
<div class="account-settings__grid">
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.username") }}</span>
|
||||
<input type="text" v-model="username" :placeholder="$t('settings.account.username')" />
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.email") }}</span>
|
||||
<input type="text" v-model="email" :placeholder="$t('settings.account.email')" />
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.newpassword") }}</span>
|
||||
<input type="password" v-model="newpassword" :placeholder="$t('settings.account.newpassword')"
|
||||
autocomplete="new-password" :class="{ 'field-error': newpassword && !isNewPasswordValid }" />
|
||||
<span v-if="newpassword && !isNewPasswordValid" class="form-error">Das neue Passwort sollte mindestens 8 Zeichen haben.</span>
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field">
|
||||
<span>{{ $t("settings.account.newpasswordretype") }}</span>
|
||||
<input type="password" v-model="newpasswordretype"
|
||||
:placeholder="$t('settings.account.newpasswordretype')" autocomplete="new-password"
|
||||
:class="{ 'field-error': newpasswordretype && !passwordsMatch }" />
|
||||
<span v-if="newpasswordretype && !passwordsMatch" class="form-error">Die Passwoerter stimmen nicht ueberein.</span>
|
||||
</label>
|
||||
|
||||
<label class="account-settings__field account-settings__field--full">
|
||||
<span>{{ $t("settings.account.oldpassword") }}</span>
|
||||
<input type="password" v-model="oldpassword" :placeholder="$t('settings.account.oldpassword')"
|
||||
autocomplete="current-password" :class="{ 'field-error': requiresOldPassword && !oldpassword.trim() }" />
|
||||
<span v-if="requiresOldPassword && !oldpassword.trim()" class="form-error">Zum Passwortwechsel wird das aktuelle Passwort benoetigt.</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.email") }} </span><input type="text" v-model="email"
|
||||
:placeholder="$t('settings.account.email')" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.newpassword") }} </span><input type="password" v-model="newpassword"
|
||||
:placeholder="$t('settings.account.newpassword')" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.newpasswordretype") }} </span><input type="password"
|
||||
v-model="newpasswordretype" :placeholder="$t('settings.account.newpasswordretype')" autocomplete="new-password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
<label><span>{{ $t("settings.account.oldpassword") }} </span><input type="password"
|
||||
v-model="oldpassword" :placeholder="$t('settings.account.oldpassword')" autocomplete="current-password" /></label>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<label class="account-settings__toggle">
|
||||
<input type="checkbox" v-model="showInSearch" />
|
||||
<span>{{ $t("settings.account.showinsearch") }}</span>
|
||||
</label>
|
||||
|
||||
<div class="account-settings__actions">
|
||||
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<label><input type="checkbox" v-model="showInSearch" /> {{ $t("settings.account.showinsearch") }}</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
|
||||
|
||||
export default {
|
||||
name: "AccountSettingsView",
|
||||
components: {},
|
||||
computed: {
|
||||
...mapGetters(['user']),
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
username: "",
|
||||
@@ -50,6 +71,18 @@ export default {
|
||||
oldpassword: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['user']),
|
||||
requiresOldPassword() {
|
||||
return this.newpassword.trim().length > 0;
|
||||
},
|
||||
isNewPasswordValid() {
|
||||
return this.newpassword.length === 0 || this.newpassword.length >= 8;
|
||||
},
|
||||
passwordsMatch() {
|
||||
return this.newpassword === this.newpasswordretype;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async changeAccount() {
|
||||
try {
|
||||
@@ -57,15 +90,19 @@ export default {
|
||||
const hasNewPassword = this.newpassword && this.newpassword.trim() !== '';
|
||||
|
||||
if (hasNewPassword) {
|
||||
if (!this.isNewPasswordValid) {
|
||||
showError(this, 'Das neue Passwort ist noch zu kurz.');
|
||||
return;
|
||||
}
|
||||
// Validiere Passwort-Wiederholung nur wenn ein neues Passwort eingegeben wurde
|
||||
if (this.newpassword !== this.newpasswordretype) {
|
||||
alert('Die Passwörter stimmen nicht überein.');
|
||||
if (!this.passwordsMatch) {
|
||||
showError(this, 'Die Passwoerter stimmen nicht ueberein.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfe ob das alte Passwort eingegeben wurde
|
||||
if (!this.oldpassword || this.oldpassword.trim() === '') {
|
||||
alert('Bitte geben Sie Ihr aktuelles Passwort ein, um das Passwort zu ändern.');
|
||||
showError(this, 'Bitte geben Sie Ihr aktuelles Passwort ein, um das Passwort zu aendern.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +126,7 @@ export default {
|
||||
// API-Aufruf zum Speichern der Account-Einstellungen
|
||||
await apiClient.post('/api/settings/set-account', accountData);
|
||||
|
||||
alert('Account-Einstellungen erfolgreich gespeichert!');
|
||||
showSuccess(this, 'Account-Einstellungen erfolgreich gespeichert.');
|
||||
|
||||
// Leere die Passwort-Felder nach erfolgreichem Speichern
|
||||
this.newpassword = '';
|
||||
@@ -98,17 +135,12 @@ export default {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
|
||||
if (error.response && error.response.data && error.response.data.error) {
|
||||
alert('Fehler: ' + error.response.data.error);
|
||||
} else {
|
||||
alert('Ein Fehler ist aufgetreten beim Speichern der Account-Einstellungen.');
|
||||
}
|
||||
showApiError(this, error, 'Ein Fehler ist beim Speichern der Account-Einstellungen aufgetreten.');
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
|
||||
console.log(response.data);
|
||||
this.username = response.data.username;
|
||||
this.showInSearch = response.data.showinsearch;
|
||||
this.email = response.data.email;
|
||||
@@ -117,18 +149,101 @@ export default {
|
||||
this.newpassword = '';
|
||||
this.newpasswordretype = '';
|
||||
this.oldpassword = '';
|
||||
|
||||
console.log(this.showInSearch);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
label {
|
||||
white-space: nowrap;
|
||||
.account-settings {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.account-settings__hero,
|
||||
.account-settings__panel {
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.account-settings__hero {
|
||||
padding: 26px 28px;
|
||||
}
|
||||
|
||||
.account-settings__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.account-settings__hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.account-settings__panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.account-settings__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.account-settings__field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-settings__field span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.account-settings__field--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.account-settings__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.account-settings__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.account-settings__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-settings__hero,
|
||||
.account-settings__panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.account-settings__actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.account-settings__actions button {
|
||||
width: 100%;
|
||||
}
|
||||
label > span {
|
||||
width: 15em;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<div class="diary-view">
|
||||
<section class="diary-hero surface-card">
|
||||
<div>
|
||||
<span class="diary-kicker">Persoenliche Eintraege</span>
|
||||
<h2>{{ $t('socialnetwork.diary.title') }}</h2>
|
||||
<p>Gedanken, Notizen und kurze Updates in einer ruhigen, persoenlichen Ansicht.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="new-entry-section">
|
||||
<section class="new-entry-section surface-card">
|
||||
<h3>{{ isEditing ? $t('socialnetwork.diary.editEntry') : $t('socialnetwork.diary.newEntry') }}</h3>
|
||||
<textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea>
|
||||
<div class="form-actions">
|
||||
@@ -9,11 +16,11 @@
|
||||
}}</button>
|
||||
<button v-if="isEditing" @click="cancelEdit">{{ $t('socialnetwork.diary.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="diaryEntries.length === 0">{{ $t('socialnetwork.diary.noEntries') }}</div>
|
||||
<div v-else class="diary-entries">
|
||||
<div v-for="entry in diaryEntries" :key="entry.id" class="diary-entry">
|
||||
<div v-if="diaryEntries.length === 0" class="diary-empty surface-card">{{ $t('socialnetwork.diary.noEntries') }}</div>
|
||||
<section v-else class="diary-entries">
|
||||
<article v-for="entry in diaryEntries" :key="entry.id" class="diary-entry surface-card">
|
||||
<p v-html="sanitizedText(entry)"></p>
|
||||
<div class="entry-info">
|
||||
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
|
||||
@@ -22,8 +29,8 @@
|
||||
<span @click="deleteEntry(entry.id)" class="button" :title="$t('socialnetwork.diary.delete')">✖</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="pagination">
|
||||
<button @click="loadDiaryEntries(currentPage - 1)" v-if="currentPage !== 1">{{
|
||||
@@ -33,6 +40,7 @@
|
||||
$t('socialnetwork.diary.nextPage') }}</button>
|
||||
</div>
|
||||
<ChooseDialog ref="chooseDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -137,13 +145,38 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.diary-view {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.diary-hero,
|
||||
.new-entry-section {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.diary-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.diary-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
height: 140px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -152,13 +185,12 @@ textarea {
|
||||
}
|
||||
|
||||
.diary-entry {
|
||||
border-bottom: 1px solid #ccc;
|
||||
margin-bottom: 1em;
|
||||
padding-bottom: 1em;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.entry-info {
|
||||
color: gray;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -176,13 +208,23 @@ textarea {
|
||||
|
||||
.pagination {
|
||||
margin-top: 1em;
|
||||
background-color: #7BBE55;
|
||||
color: #fff;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diary-entries {
|
||||
width: 400px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diary-empty {
|
||||
padding: 22px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<template>
|
||||
<h2 class="link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
|
||||
<h3 v-if="forumTopic">{{ forumTopic }}</h3>
|
||||
<div class="forum-topic-view">
|
||||
<section class="forum-topic-hero surface-card">
|
||||
<div>
|
||||
<div class="forum-topic-back link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</div>
|
||||
<h2 v-if="forumTopic">{{ forumTopic }}</h2>
|
||||
<p>Diskussionen, Antworten und neue Beitraege in einer fokussierten Leseflaeche.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="forum-topic-messages">
|
||||
<ul class="messages">
|
||||
<li v-for="message in messages" :key="message.id">
|
||||
<li v-for="message in messages" :key="message.id" class="surface-card">
|
||||
<div v-html="sanitizedMessage(message)"></div>
|
||||
<div class="footer">
|
||||
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
|
||||
@@ -12,11 +20,13 @@
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="editor-container">
|
||||
<div class="editor-container surface-card">
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
</div>
|
||||
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -98,6 +108,27 @@ export default {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.forum-topic-view {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.forum-topic-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-topic-back {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.forum-topic-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.messages {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
@@ -105,14 +136,13 @@ export default {
|
||||
}
|
||||
|
||||
.messages > li {
|
||||
border: 1px solid #7BBE55;
|
||||
margin-bottom: 0.25em;
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0.75em;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.messages > li > .footer {
|
||||
color: #F9A22C;
|
||||
font-size: 0.7em;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8em;
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
}
|
||||
@@ -127,10 +157,10 @@ export default {
|
||||
|
||||
.editor-container {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #ccc;
|
||||
padding: 0;
|
||||
min-height: 260px;
|
||||
background-color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor {
|
||||
@@ -141,7 +171,7 @@ export default {
|
||||
.editor :deep(.ProseMirror) {
|
||||
min-height: 260px;
|
||||
outline: none;
|
||||
padding: 10px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<div class="forum-view">
|
||||
<section class="forum-hero surface-card">
|
||||
<div>
|
||||
<span class="forum-kicker">Community-Forum</span>
|
||||
<h2>{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
|
||||
<p>Themen, Diskussionen und neue Beitraege an einem strukturierten Ort.</p>
|
||||
</div>
|
||||
<div class="creationtoggler">
|
||||
<button @click="createNewTopic">
|
||||
{{ $t(!inCreation
|
||||
@@ -7,37 +13,43 @@
|
||||
: 'socialnetwork.forum.hideNewTopic') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="inCreation">
|
||||
<div>
|
||||
<section v-if="inCreation" class="forum-creation surface-card">
|
||||
<label class="newtitle">
|
||||
{{ $t('socialnetwork.forum.topic') }}
|
||||
<span>{{ $t('socialnetwork.forum.topic') }}</span>
|
||||
<input type="text" v-model="newTitle" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<EditorContent v-if="editor" :editor="editor" class="editor" />
|
||||
</div>
|
||||
<button @click="saveNewTopic">
|
||||
{{ $t('socialnetwork.forum.createNewTopic') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else-if="titles.length > 0">
|
||||
<!-- hier kommt deine bestehende TABLE + PAGINATION hin -->
|
||||
<table>
|
||||
<!-- Kopfzeile, Spalten etc. -->
|
||||
</table>
|
||||
<section v-else-if="titles.length > 0" class="forum-topics surface-card">
|
||||
<ul class="topic-list">
|
||||
<li v-for="topic in titles" :key="topic.id" class="topic-card">
|
||||
<button type="button" class="topic-card__main" @click="openTopic(topic.id)">
|
||||
<strong>{{ topic.title }}</strong>
|
||||
<span class="topic-card__meta">
|
||||
{{ topic.user?.username || topic.owner?.username || 'Community' }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="pagination">
|
||||
<button @click="goToPage(page-1)" :disabled="page<=1">‹</button>
|
||||
<span>{{ page }} / {{ totalPages }}</span>
|
||||
<button @click="goToPage(page+1)" :disabled="page>=totalPages">›</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-else>
|
||||
<div v-else class="forum-empty surface-card">
|
||||
{{ $t('socialnetwork.forum.noTitles') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -156,20 +168,60 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.creationtoggler {
|
||||
margin-bottom: 1em;
|
||||
.forum-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.forum-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.forum-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 162, 43, 0.14);
|
||||
color: #8a5411;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.forum-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.creationtoggler {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.forum-creation,
|
||||
.forum-topics,
|
||||
.forum-empty {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.newtitle {
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.newtitle input {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
margin: 1em 0;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0;
|
||||
min-height: 260px;
|
||||
background-color: white;
|
||||
@@ -189,16 +241,62 @@ export default {
|
||||
.editor :deep(.ProseMirror p) { margin: 0 0 .6rem; }
|
||||
.editor :deep(.ProseMirror p:first-child) { margin-top: 0; }
|
||||
.editor :deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); }
|
||||
|
||||
.topic-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topic-card + .topic-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.topic-card__main {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: none;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.topic-card__main strong {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.topic-card__meta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
.pagination button {
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.forum-empty {
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.forum-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.topic-card__main {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
<template>
|
||||
<div class="friends-view">
|
||||
<section class="friends-hero surface-card">
|
||||
<div>
|
||||
<span class="friends-kicker">Community</span>
|
||||
<h2>{{ $t('friends.title') }}</h2>
|
||||
<p>Freundschaften, offene Anfragen und laufende Kontakte an einem Ort.</p>
|
||||
</div>
|
||||
<div class="friends-stats">
|
||||
<div class="friends-stat surface-card">
|
||||
<strong>{{ tabs[0].data.length }}</strong>
|
||||
<span>Bestehend</span>
|
||||
</div>
|
||||
<div class="friends-stat surface-card">
|
||||
<strong>{{ tabs[1].data.length + tabs[2].data.length }}</strong>
|
||||
<span>Offen</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="tabs-container">
|
||||
<div class="tab" v-for="(tab, index) in tabs" :key="tab.name" :class="{ active: activeTab === index }"
|
||||
@click="selectTab(index)">
|
||||
{{ $t(tab.label) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="(tab, index) in tabs" v-show="activeTab === index" :key="tab.name">
|
||||
<div v-for="(tab, index) in tabs" v-show="activeTab === index" :key="tab.name" class="friends-panel surface-card">
|
||||
<v-data-table :items="paginatedData(tab.data, tab.pagination.page)" :headers="headers"
|
||||
:items-per-page="tab.pagination.itemsPerPage" class="elevation-1">
|
||||
<template v-slot:body="{ items }">
|
||||
@@ -167,25 +183,85 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.friends-view {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.friends-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.friends-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.friends-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.friends-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.friends-stat {
|
||||
min-width: 120px;
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.friends-stat strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.friends-stat span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #999;
|
||||
padding: 5px 0;
|
||||
gap: 8px;
|
||||
padding: 0 0 12px;
|
||||
border-bottom: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 2px 4px;
|
||||
padding: 8px 14px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: bold;
|
||||
border: 1px solid #999;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.tab:not(.active):hover {
|
||||
background-color: #ddd;
|
||||
background-color: rgba(248, 162, 43, 0.12);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
@@ -194,6 +270,10 @@ export default {
|
||||
border-color: #F9A22C;
|
||||
}
|
||||
|
||||
.friends-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.font-color-gender-male {
|
||||
color: #1E90FF;
|
||||
}
|
||||
@@ -205,4 +285,11 @@ export default {
|
||||
.font-color-gender-nonbinary {
|
||||
color: #DAA520;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.friends-hero {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<div class="gallery-page">
|
||||
<section class="gallery-hero surface-card">
|
||||
<div>
|
||||
<span class="gallery-kicker">Bilder und Ordner</span>
|
||||
<h2>{{ $t('socialnetwork.gallery.title') }}</h2>
|
||||
<p>Eigene Inhalte organisieren, sichtbar machen und in Ordnern strukturieren.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="gallery-view">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar surface-card">
|
||||
<h3>{{ $t('socialnetwork.gallery.folders') }}</h3>
|
||||
<ul class="tree">
|
||||
<folder-item v-for="folder in [folders]" :key="folder.id" :folder="folder"
|
||||
@@ -13,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="upload-section">
|
||||
<div class="upload-section surface-card">
|
||||
<div class="upload-header" @click="toggleUploadSection">
|
||||
<span>
|
||||
<i class="icon-upload-toggle">{{ isUploadVisible ? '▲' : '▼' }}</i>
|
||||
@@ -63,9 +70,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="image-list">
|
||||
<div class="image-list surface-card">
|
||||
<h3>{{ $t('socialnetwork.gallery.images') }}</h3>
|
||||
<ul v-if="images.length > 0">
|
||||
<ul v-if="images.length > 0" class="image-grid">
|
||||
<li v-for="image in images" :key="image.id" @click="openImageDialog(image)">
|
||||
<img :src="image.url || image.placeholder" alt="Loading..." />
|
||||
<p>{{ image.title }}</p>
|
||||
@@ -75,6 +82,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -265,35 +273,95 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gallery-page {
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.gallery-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gallery-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.gallery-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.gallery-view {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
margin-right: 20px;
|
||||
width: 240px;
|
||||
margin-right: 0;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.image-list li {
|
||||
margin: 4px;
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-grid li {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-grid p {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-list li img {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.icon-upload-toggle {
|
||||
float: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -302,51 +370,23 @@ export default {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder-item.selected {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.image-list > ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-list > ul > li {
|
||||
display: inline-block;
|
||||
padding: 2px;
|
||||
border: 1px solid #F9A22C;
|
||||
}
|
||||
|
||||
.image-list > ul > li > p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-list li img {
|
||||
max-width: 200px;
|
||||
max-height: 200px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
color: red;
|
||||
.upload-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tree {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.gallery-view {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<template>
|
||||
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2>
|
||||
<div class="guestbook-view">
|
||||
<section class="guestbook-hero surface-card">
|
||||
<div>
|
||||
<div v-if="guestbookEntries.length === 0">{{ $t('socialnetwork.profile.guestbook.noEntries') }}
|
||||
<span class="guestbook-kicker">Gaestebuch</span>
|
||||
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2>
|
||||
<p>Nachrichten, Rueckmeldungen und kleine Einblicke aus deinem Netzwerk.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div v-if="guestbookEntries.length === 0" class="guestbook-empty surface-card">{{ $t('socialnetwork.profile.guestbook.noEntries') }}
|
||||
</div>
|
||||
<div v-else class="guestbook-entries">
|
||||
<div v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry">
|
||||
<article v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry surface-card">
|
||||
<img v-if="entry.image" :src="entry.image.url" alt="Entry Image"
|
||||
style="max-width: 400px; max-height: 400px;" />
|
||||
class="guestbook-image" />
|
||||
<p v-html="sanitizedContent(entry)"></p>
|
||||
<div class="entry-info">
|
||||
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
|
||||
@@ -14,7 +20,7 @@
|
||||
<span @click="openProfile(entry.senderUsername)">{{ entry.sender }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<button @click="loadGuestbookEntries(currentPage - 1)" v-if="currentPage !== 1">{{
|
||||
@@ -85,10 +91,72 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.guestbook-view {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.guestbook-hero,
|
||||
.guestbook-empty {
|
||||
padding: 22px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.guestbook-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.guestbook-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.guestbook-entries {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guestbook-entry {
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.guestbook-image {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.entry-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.entry-user span {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 1em;
|
||||
background-color: #7BBE55;
|
||||
color: #fff;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.5em 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +1,52 @@
|
||||
<template>
|
||||
<div class="search-view">
|
||||
<section class="search-hero surface-card">
|
||||
<div>
|
||||
<span class="search-kicker">Community-Suche</span>
|
||||
<h2>{{ $t('socialnetwork.usersearch.title') }}</h2>
|
||||
<p>Mit Namen, Alter und Geschlecht gezielt passende Kontakte in der Community finden.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="search-form surface-card">
|
||||
<form @submit.prevent="performSearch">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('socialnetwork.usersearch.username') }}:</label>
|
||||
<label for="username">{{ $t('socialnetwork.usersearch.username') }}</label>
|
||||
<input type="text" id="username" v-model="searchCriteria.username"
|
||||
:placeholder="$t('socialnetwork.usersearch.username')" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ageFrom">{{ $t('socialnetwork.usersearch.age_from') }}:</label>
|
||||
<div class="form-group form-group--age">
|
||||
<label for="ageFrom">{{ $t('socialnetwork.usersearch.age_from') }}</label>
|
||||
<div class="age-range">
|
||||
<input type="number" id="ageFrom" v-model="searchCriteria.ageFrom" :min="14" :max="150"
|
||||
:placeholder="$t('socialnetwork.usersearch.age_from')" class="age-input" />
|
||||
<label for="ageTo">{{ $t('socialnetwork.usersearch.age_to') }}:</label>
|
||||
<span class="age-separator">bis</span>
|
||||
<input type="number" id="ageTo" v-model="searchCriteria.ageTo" :min="14" :max="150"
|
||||
:placeholder="$t('socialnetwork.usersearch.age_to')" class="age-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gender">{{ $t('socialnetwork.usersearch.gender') }}:</label>
|
||||
<label for="gender">{{ $t('socialnetwork.usersearch.gender') }}</label>
|
||||
<multiselect v-model="searchCriteria.gender" :options="genderOptions" :multiple="true"
|
||||
:close-on-select="false" :placeholder="$t('socialnetwork.usersearch.gender')" label="name"
|
||||
track-by="name" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="search-button">{{ $t('socialnetwork.usersearch.search_button') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div class="search-results" v-if="searchResults.length">
|
||||
<section class="search-results surface-card" v-if="searchResults.length">
|
||||
<div class="results-header">
|
||||
<h3>{{ $t('socialnetwork.usersearch.results_title') }}</h3>
|
||||
<span class="results-count">{{ searchResults.length }} Treffer</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -47,8 +63,8 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="no-results">
|
||||
</section>
|
||||
<div v-else class="no-results surface-card">
|
||||
{{ $t('socialnetwork.usersearch.no_results') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,83 +130,117 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.search-view {
|
||||
max-width: 600px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
.search-hero,
|
||||
.search-form,
|
||||
.search-results,
|
||||
.no-results {
|
||||
padding: 22px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.search-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 120px;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
input,
|
||||
.multiselect__input {
|
||||
flex: 1;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
.age-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.age-input {
|
||||
width: 70px;
|
||||
margin-right: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
margin-top: 20px;
|
||||
.age-separator {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.88rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-results ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
.form-actions {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.search-results li {
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
border-bottom: 1px solid #ddd;
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
padding: 0;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
thead {
|
||||
color: #7BBE55;
|
||||
color: #42634e;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding-right: 1em;
|
||||
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
th, td:not:last-child {
|
||||
border-bottom: 1px solid #7E471B;
|
||||
tbody tr + tr td {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.g-male {
|
||||
@@ -200,4 +250,14 @@ th, td:not:last-child {
|
||||
.g-female {
|
||||
color: #ff3377;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.age-range {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
<template>
|
||||
<div class="vocab-chapter-view">
|
||||
<section class="vocab-chapter-hero surface-card">
|
||||
<span class="vocab-chapter-hero__eyebrow">Vokabeltrainer</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
|
||||
<p>Kapitelinhalt durchsuchen, Vokabeln pflegen und direkt in die Uebung wechseln.</p>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<section class="box surface-card">
|
||||
<div v-if="loading">{{ $t('general.loading') }}</div>
|
||||
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
|
||||
<div v-else>
|
||||
<div v-show="!practiceOpen">
|
||||
<div class="row">
|
||||
<div class="row row--actions">
|
||||
<button @click="back">{{ $t('general.back') }}</button>
|
||||
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
|
||||
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="row" v-if="chapter.isOwner">
|
||||
<div class="editor-card" v-if="chapter.isOwner">
|
||||
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
|
||||
<div class="grid">
|
||||
<label>
|
||||
{{ $t('socialnetwork.vocab.learningWord') }}
|
||||
<span>{{ $t('socialnetwork.vocab.learningWord') }}</span>
|
||||
<input v-model="learning" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('socialnetwork.vocab.referenceWord') }}
|
||||
<span>{{ $t('socialnetwork.vocab.referenceWord') }}</span>
|
||||
<input v-model="reference" type="text" />
|
||||
</label>
|
||||
</div>
|
||||
@@ -29,10 +34,9 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div v-if="vocabs.length === 0">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
|
||||
<table v-else class="tbl">
|
||||
<div v-if="vocabs.length === 0" class="empty-state">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
|
||||
@@ -49,6 +53,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<VocabPracticeDialog ref="practiceDialog" />
|
||||
<VocabSearchDialog ref="searchDialog" />
|
||||
@@ -147,30 +153,120 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
display: inline-block;
|
||||
.vocab-chapter-view {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.vocab-chapter-hero,
|
||||
.box {
|
||||
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.vocab-chapter-hero,
|
||||
.box {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.vocab-chapter-hero__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-secondary-soft);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.vocab-chapter-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.row--actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 18px 0 20px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.grid label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid span {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tbl th,
|
||||
.tbl td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tbl th {
|
||||
background: rgba(248, 162, 43, 0.12);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.vocab-chapter-hero,
|
||||
.box {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<div class="vocab-course-list">
|
||||
<section class="vocab-courses-hero surface-card">
|
||||
<div>
|
||||
<span class="vocab-courses-kicker">Kurse</span>
|
||||
<h2>{{ $t('socialnetwork.vocab.courses.title') }}</h2>
|
||||
<p>Oeffentliche und eigene Lernkurse filtern, finden und direkt weiterlernen.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="box">
|
||||
<div class="box surface-card">
|
||||
<div class="actions">
|
||||
<button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button>
|
||||
<button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button>
|
||||
@@ -361,14 +367,37 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.vocab-course-list {
|
||||
padding: 20px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 0 24px;
|
||||
}
|
||||
|
||||
.vocab-courses-hero {
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vocab-courses-kicker {
|
||||
display: inline-block;
|
||||
margin-bottom: 10px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(120, 195, 138, 0.14);
|
||||
color: #42634e;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.vocab-courses-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.box {
|
||||
background: #f6f6f6;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user