Compare commits

33 Commits

Author SHA1 Message Date
Torsten Schulz (local)
9d44a265ca Refactor backend CORS settings to include default origins and improve error handling in chat services: Introduce dynamic CORS origin handling, enhance RabbitMQ message sending with fallback mechanisms, and update WebSocket service to manage pending messages. Update UI components for better accessibility and responsiveness, including adjustments to dialog and navigation elements. Enhance styling for improved user experience across various components. 2026-03-19 14:44:04 +01:00
Torsten Schulz (local)
4442937ebd Enhance backend configuration and error handling: Update CORS settings to allow dynamic origins, improve RabbitMQ connection handling in chat services, and adjust API server host configuration. Refactor environment variables for better flexibility and add fallback mechanisms for WebSocket and chat services. Update frontend environment files for consistent API and WebSocket URLs. 2026-03-18 22:45:22 +01:00
Torsten Schulz (local)
59869e077e Update SEO and meta tags in index.html, enhance robots.txt for better crawling control, and improve sitemap.xml priorities. Refactor blog routes to include SEO metadata and adjust blog view for canonical URLs. Implement blog URL generation in BlogListView and apply SEO dynamically in BlogView. 2026-03-18 22:02:44 +01:00
Torsten Schulz (local)
971e09a72a Add Bisaya course content for 'Ort & Richtung' and 'Alltagsgespräche - Teil 2' lessons: Introduce multiple-choice, gap-fill, and transformation exercises focusing on location and everyday conversation vocabulary, complete with translations and explanations. 2026-03-05 13:28:08 +01:00
Torsten Schulz (local)
bf2b490731 Add Bisaya course content for 'Haus & Familie' lesson: Introduce multiple-choice and gap-fill exercises for vocabulary related to house and family terms, including translations and explanations. Update lesson tracking to include the new lesson in the course content. 2026-03-05 13:23:56 +01:00
Torsten Schulz (local)
fd41a53404 Implement password prompt UI and logic in MultiChatDialog: Add a password entry panel with validation and error handling for room access. Update i18n files for localized password prompts in English, German, and Spanish. 2026-03-04 23:34:55 +01:00
Torsten Schulz (local)
a48e907e50 Add password protection feature in MultiChatDialog: Implement room password management, including prompts for password entry and error handling for invalid passwords. Update i18n files with localized messages for password prompts in English, German, and Spanish. 2026-03-04 23:32:32 +01:00
Torsten Schulz (local)
a117bad342 Enhance room creation tracking in MultiChatDialog: Implement logic to confirm room creation success, manage pending room creation attempts, and clear tracking on dialog close. Update i18n files with new localized messages for room creation status. 2026-03-04 23:28:54 +01:00
Torsten Schulz (local)
190cf626f9 Add functionality for managing user-owned chat rooms: Implement getOwnRooms and deleteOwnRoom methods in ChatController and ChatService, add corresponding API routes in chatRouter, and enhance MultiChatDialog for displaying and deleting owned rooms with localized messages. Update i18n files for new features. 2026-03-04 23:22:16 +01:00
Torsten Schulz (local)
2bc34acacf Add room creation options endpoint and integrate with chat UI: Implement getRoomCreateOptions in ChatController and ChatService, add corresponding API route, and enhance MultiChatDialog for room creation with localized labels and validation. Update i18n files for new room creation features. 2026-03-04 23:12:54 +01:00
Torsten Schulz (local)
5f4acbea51 Remove publicFlag from room creation form in MultiChatDialog: Simplify visibility handling by directly using the visibility state to determine public/private status, enhancing clarity and reducing redundancy. 2026-03-04 22:59:08 +01:00
Torsten Schulz (local)
6d4ada7b31 Refactor getRaceLimit method in MultiChatDialog: Simplify logic by returning a fixed value of 1, ensuring consistent race limit handling. 2026-03-04 22:53:39 +01:00
Torsten Schulz (local)
1bccee3429 Update WebSocket connection handling in MultiChatDialog: Change raceLimit to 1 to prevent duplicate daemon sockets and ensure only one connection attempt in parallel. 2026-03-04 22:50:54 +01:00
Torsten Schulz (local)
947d3d0694 Add validation and error handling for room creation form in MultiChatDialog: Implement input validation for room name, age restrictions, password, and access rights. Enhance UI with error messages and disable button when validation fails. 2026-03-04 22:44:15 +01:00
Torsten Schulz (local)
e76fdbe1ab Implement room creation panel in MultiChatDialog: Add functionality for users to create new chat rooms with customizable settings, including visibility, age restrictions, and password protection. Enhance UI with a toggle button and form for room details. 2026-03-04 22:42:48 +01:00
Torsten Schulz (local)
db8be34607 Update room management in AdminController: Modify updateRoom and deleteRoom methods to include userId as a parameter for improved access control. 2026-03-04 22:38:24 +01:00
Torsten Schulz (local)
407c3b359b Update chat configuration and remove MultiChat component: Change chat port to 1236 in chatBridge.json, update WebSocket URL in .env.local, and delete the MultiChat.vue component to streamline chat functionality. 2026-03-04 17:24:15 +01:00
Torsten Schulz (local)
a2652c983f Füge neue Funktionen zur Verwaltung von Erben hinzu: Implementiere die API-Endpunkte zum Abrufen potenzieller Erben und zum Auswählen eines Erben. Ergänze die Logik in FalukantService zur Verarbeitung dieser Funktionen. 2026-03-02 00:36:43 +01:00
Torsten Schulz (local)
42fe568e2b Verbessere die Behandlung von Charaktereigenschaften beim Versenden von Geschenken: Füge eine Überprüfung hinzu, um sicherzustellen, dass characterTraits ein Array ist, und behandle fehlende Traits als neutralen Wert. 2026-02-14 16:48:23 +01:00
Torsten Schulz (local)
ea7f8d1acc Verbessere die Sicherheitsüberprüfung der Benutzermerkmale in der Geschenksuche: Füge eine sichere Trait-Filterung hinzu, um Fehler bei undefinierten Eigenschaften zu vermeiden. 2026-02-14 16:44:51 +01:00
Torsten Schulz (local)
af4e5de1ad Normalisiere eingehende API-Daten: Akzeptiere sowohl camelCase als auch snake_case für die Eigenschaften des Falukant-Datenobjekts. 2026-02-14 16:41:14 +01:00
Torsten Schulz (local)
cc80081280 Passe die Schlüssel in den Arrays für Stimmungen und Charaktere an snake_case an 2026-02-14 16:38:57 +01:00
Torsten Schulz (local)
444a1b9dcc Verbessere die Handhabung des Ladens von .env-Dateien: Füge Lesbarkeitsprüfung für Produktions-.env hinzu und verbessere Fehlerbehandlung beim Laden. 2026-02-14 16:22:22 +01:00
Torsten Schulz (local)
91637ba7a3 Füge Validierung für Geschenke in FalukantService hinzu und erstelle Skripte zur Reparatur ungültiger Werte in PromotionalGift 2026-02-14 16:19:31 +01:00
Torsten Schulz (local)
be7db6ad96 Verbessere die Berechnung der Geschenkekosten in FalukantService und füge Tests für die Funktion hinzu 2026-02-14 16:12:07 +01:00
Torsten Schulz (local)
a3b550859c Korrigiere Zuweisung von Beziehungen und verbessere Trait-IDs-Verarbeitung in FalukantService 2026-02-14 15:58:01 +01:00
Torsten Schulz (local)
c58f8c0bf8 Entferne Debug-Logs für Alters- und Geschlechtsbezeichnungen in FalukantWidget 2026-02-09 17:29:10 +01:00
Torsten Schulz (local)
73304e8af4 Verbessere die Handhabung von Altersgruppen in FalukantWidget: Lese Rohwerte direkt aus i18n-Nachrichten, um Plural/Choice-Format zu vermeiden. 2026-02-09 17:26:36 +01:00
Torsten Schulz (local)
e21c61b5e3 Füge Debug-Logs für Alters- und Geschlechtsbezeichnungen in FalukantWidget hinzu 2026-02-09 17:18:44 +01:00
Torsten Schulz (local)
78a44b5189 Füge Altersgruppenübersetzungen in genderAge hinzu: Ergänze männliche, weibliche und neutrale Bezeichnungen für verschiedene Altersgruppen. 2026-02-09 17:12:49 +01:00
Torsten Schulz (local)
da1d912bdb Korrigiere Altersgruppenlogik in FalukantWidget: Überarbeite die Altersberechnung und passe die Kommentare für Klarheit an. 2026-02-09 17:06:30 +01:00
Torsten Schulz (local)
c45a843611 Aktualisiere die Altersberechnung in FalukantWidget: Ändere die Umrechnung von Tagen in Jahre und runde auf das nächste Jahr für eine erwartbare Anzeige. 2026-02-09 17:02:41 +01:00
Torsten Schulz (local)
b07099b57d Refactor code structure for improved readability and maintainability 2026-02-09 16:50:25 +01:00
106 changed files with 10002 additions and 2571 deletions

View File

@@ -36,6 +36,19 @@ const app = express();
// - LOG_ALL_REQ=1: Logge alle Requests // - LOG_ALL_REQ=1: Logge alle Requests
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1'; 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 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) => { app.use((req, res, next) => {
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex')); const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
req.reqId = reqId; req.reqId = reqId;
@@ -51,15 +64,26 @@ app.use((req, res, next) => {
}); });
const corsOptions = { 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'], methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'userId', 'authCode'], allowedHeaders: ['Content-Type', 'Authorization', 'userid', 'authcode', 'userId', 'authCode'],
credentials: true, credentials: true,
preflightContinue: false, preflightContinue: false,
optionsSuccessStatus: 204 optionsSuccessStatus: 204
}; };
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(express.json()); // To handle JSON request bodies app.use(express.json()); // To handle JSON request bodies
app.use('/api/chat', chatRouter); app.use('/api/chat', chatRouter);

View File

@@ -1,4 +1,4 @@
{ {
"host": "localhost", "host": "localhost",
"port": 1235 "port": 1236
} }

View File

@@ -12,25 +12,51 @@ const productionEnvPath = '/opt/yourpart/backend/.env';
const localEnvPath = path.resolve(__dirname, '../.env'); const localEnvPath = path.resolve(__dirname, '../.env');
let envPath = localEnvPath; // Fallback let envPath = localEnvPath; // Fallback
let usingProduction = false;
if (fs.existsSync(productionEnvPath)) { if (fs.existsSync(productionEnvPath)) {
// Prüfe Lesbarkeit bevor wir versuchen, sie zu laden
try {
fs.accessSync(productionEnvPath, fs.constants.R_OK);
envPath = productionEnvPath; 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 { } 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] Versuche .env zu laden von:', envPath);
console.log('[env] Datei existiert:', fs.existsSync(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'); let result;
try {
const result = dotenv.config({ path: envPath }); result = dotenv.config({ path: envPath });
if (result.error) { if (result.error) {
console.warn('[env] Konnte .env nicht laden:', result.error.message); console.warn('[env] Konnte .env nicht laden:', result.error.message);
console.warn('[env] Fehler-Details:', result.error); console.warn('[env] Fehler-Details:', result.error);
} else { } 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 || {})); 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 // Debug: Zeige Redis-Konfiguration

View File

@@ -13,6 +13,9 @@ class ChatController {
this.sendOneToOneMessage = this.sendOneToOneMessage.bind(this); this.sendOneToOneMessage = this.sendOneToOneMessage.bind(this);
this.getOneToOneMessageHistory = this.getOneToOneMessageHistory.bind(this); this.getOneToOneMessageHistory = this.getOneToOneMessageHistory.bind(this);
this.getRoomList = this.getRoomList.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) { async getMessages(req, res) {
@@ -175,6 +178,41 @@ class ChatController {
res.status(500).json({ error: error.message }); 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; export default ChatController;

View File

@@ -98,6 +98,8 @@ class FalukantController {
if (!result) throw { status: 404, message: 'No family data found' }; if (!result) throw { status: 404, message: 'No family data found' };
return result; 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.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.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
this.cancelWooing = this._wrapWithUser(async (userId) => { this.cancelWooing = this._wrapWithUser(async (userId) => {

View 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;

View File

@@ -15,5 +15,8 @@ router.post('/initOneToOne', authenticate, chatController.initOneToOne);
router.post('/oneToOne/sendMessage', authenticate, chatController.sendOneToOneMessage); // Neue Route zum Senden einer Nachricht 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('/oneToOne/messageHistory', authenticate, chatController.getOneToOneMessageHistory); // Neue Route zum Abrufen der Nachrichtengeschichte
router.get('/rooms', chatController.getRoomList); 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; export default router;

View File

@@ -47,6 +47,8 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal); router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/cancel-wooing', falukantController.cancelWooing); router.post('/family/cancel-wooing', falukantController.cancelWooing);
router.post('/family/set-heir', falukantController.setHeir); 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/gifts', falukantController.getGifts);
router.get('/family/children', falukantController.getChildren); router.get('/family/children', falukantController.getChildren);
router.post('/family/gift', falukantController.sendGift); router.post('/family/gift', falukantController.sendGift);

View File

@@ -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 // Lektion 15: Zeitformen - Grundlagen
'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 (Lektion 9)
'Woche 1 - Wiederholung': [ 'Woche 1 - Wiederholung': [
{ {
@@ -971,7 +1270,10 @@ async function createBisayaCourseContent() {
const replacePlaceholders = [ const replacePlaceholders = [
'Woche 1 - Wiederholung', 'Woche 1 - Wiederholung',
'Woche 1 - Vokabeltest', 'Woche 1 - Vokabeltest',
'Alltagsgespräche - Teil 1' 'Alltagsgespräche - Teil 1',
'Alltagsgespräche - Teil 2',
'Haus & Familie',
'Ort & Richtung'
].includes(lesson.title); ].includes(lesson.title);
const existingCount = await VocabGrammarExercise.count({ const existingCount = await VocabGrammarExercise.count({
where: { lessonId: lesson.id } where: { lessonId: lesson.id }

View File

@@ -13,7 +13,8 @@ import { setupWebSocket } from './utils/socket.js';
import { syncDatabase } from './utils/syncDatabase.js'; import { syncDatabase } from './utils/syncDatabase.js';
// HTTP-Server für API (Port 2020, intern, über Apache-Proxy) // 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); const httpServer = http.createServer(app);
// Socket.io wird nur auf HTTPS-Server bereitgestellt, nicht auf HTTP-Server // Socket.io wird nur auf HTTPS-Server bereitgestellt, nicht auf HTTP-Server
// setupWebSocket(httpServer); // Entfernt: Socket.io nur über HTTPS // 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_KEY_PATH = process.env.SOCKET_IO_TLS_KEY_PATH;
const TLS_CERT_PATH = process.env.SOCKET_IO_TLS_CERT_PATH; const TLS_CERT_PATH = process.env.SOCKET_IO_TLS_CERT_PATH;
const TLS_CA_PATH = process.env.SOCKET_IO_TLS_CA_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) { if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
try { try {
@@ -45,14 +47,14 @@ if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
syncDatabase().then(() => { syncDatabase().then(() => {
// API-Server auf Port 2020 (intern, nur localhost) // API-Server auf Port 2020 (intern, nur localhost)
httpServer.listen(API_PORT, '127.0.0.1', () => { httpServer.listen(API_PORT, API_HOST, () => {
console.log(`[API] HTTP-Server läuft auf localhost:${API_PORT} (intern, über Apache-Proxy)`); console.log(`[API] HTTP-Server läuft auf ${API_HOST}:${API_PORT}`);
}); });
// Socket.io-Server auf Port 4443 (extern, direkt erreichbar) // Socket.io-Server auf Port 4443 (extern, direkt erreichbar)
if (httpsServer) { if (httpsServer) {
httpsServer.listen(SOCKET_IO_PORT, '0.0.0.0', () => { httpsServer.listen(SOCKET_IO_PORT, SOCKET_IO_HOST, () => {
console.log(`[Socket.io] HTTPS-Server läuft auf Port ${SOCKET_IO_PORT} (direkt erreichbar)`); console.log(`[Socket.io] HTTPS-Server läuft auf ${SOCKET_IO_HOST}:${SOCKET_IO_PORT}`);
}); });
} }
}).catch(err => { }).catch(err => {

View File

@@ -1,7 +1,9 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import amqp from 'amqplib/callback_api.js'; 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'; const QUEUE = 'oneToOne_messages';
class ChatService { class ChatService {
@@ -11,11 +13,37 @@ class ChatService {
this.users = []; this.users = [];
this.randomChats = []; this.randomChats = [];
this.oneToOneChats = []; this.oneToOneChats = [];
this.channel = null;
this.amqpAvailable = false;
this.initRabbitMq();
}
initRabbitMq() {
amqp.connect(RABBITMQ_URL, (err, connection) => { amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) throw err; if (err) {
connection.createChannel((err, channel) => { console.warn(`[chatService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - fallback ohne Queue wird verwendet.`);
if (err) throw err; 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.channel = channel;
this.amqpAvailable = true;
channel.assertQueue(QUEUE, { durable: false }); channel.assertQueue(QUEUE, { durable: false });
}); });
}); });
@@ -116,8 +144,14 @@ class ChatService {
history: [messageBundle], history: [messageBundle],
}); });
} }
if (this.channel) { if (this.channel && this.amqpAvailable) {
try {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle))); 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(); export default new ChatService();

View File

@@ -2,7 +2,10 @@ import net from 'net';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; 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() { function loadBridgeConfig() {
try { try {

View File

@@ -389,6 +389,8 @@ class FalukantService extends BaseService {
one: { min: 50, max: 5000 }, one: { min: 50, max: 5000 },
all: { min: 400, max: 40000 } all: { min: 400, max: 40000 }
}; };
static WOOING_PROGRESS_TARGET = 70;
static WOOING_GIFT_COOLDOWN_MS = 30 * 60 * 1000;
static HEALTH_ACTIVITIES = [ static HEALTH_ACTIVITIES = [
{ tr: "barber", method: "healthBarber", cost: 10 }, { tr: "barber", method: "healthBarber", cost: 10 },
{ tr: "doctor", method: "healthDoctor", cost: 50 }, { tr: "doctor", method: "healthDoctor", cost: 50 },
@@ -2786,7 +2788,7 @@ class FalukantService extends BaseService {
for (const c of character2s) { for (const c of character2s) {
c.setDataValue('traits', traitsByChar[c.id] || []); c.setDataValue('traits', traitsByChar[c.id] || []);
} }
const relationships = relRows.map(r => { relationships = relRows.map(r => {
const c2 = char2Map[r.character2Id]; const c2 = char2Map[r.character2Id];
const type = typeMap[r.relationshipTypeId]; const type = typeMap[r.relationshipTypeId];
return { return {
@@ -2910,6 +2912,97 @@ class FalukantService extends BaseService {
return { success: true, childCharacterId }; 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) { async getPossiblePartners(requestingCharacterId) {
const proposals = await MarriageProposal.findAll({ const proposals = await MarriageProposal.findAll({
where: { where: {
@@ -3087,7 +3180,7 @@ class FalukantService extends BaseService {
relatedChar.setDataValue('traits', traits); relatedChar.setDataValue('traits', traits);
// 3) Trait-IDs und Mood des relatedChar // 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; const relatedMoodId = relatedChar.moodId;
// 4) Gifts laden aber nur die passenden Moods und Traits als Unter-Arrays // 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 // 5) Rest wie gehabt: Kosten berechnen und zurückgeben
const lowestTitleOfNobility = await TitleOfNobility.findOne({ order: [['id', 'ASC']] }); 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, id: gift.id,
name: gift.name, name: gift.name,
cost: await this.getGiftCost( cost,
gift.value, // Frontend erwartet snake_case keys (mood_id / trait_id) in these arrays
myChar.titleOfNobility, moodsAffects: (gift.promotionalgiftmoods || []).map(m => ({
lowestTitleOfNobility.id mood_id: m.moodId ?? m.mood_id ?? m.moodId,
), suitability: m.suitability
moodsAffects: gift.promotionalgiftmoods, // nur Einträge mit relatedMoodId })),
charactersAffects: gift.characterTraits // nur Einträge mit relatedTraitIds charactersAffects: (gift.characterTraits || []).map(ct => ({
}))); trait_id: ct.traitId ?? ct.trait_id ?? ct.traitId,
suitability: ct.suitability
}))
};
}));
} }
async getChildren(hashedUserId) { async getChildren(hashedUserId) {
@@ -3196,19 +3313,23 @@ class FalukantService extends BaseService {
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
limit: 1 limit: 1
}); });
if (lastGift && (lastGift.createdAt.getTime() + 3_600_000) > Date.now()) { if (lastGift && (lastGift.createdAt.getTime() + FalukantService.WOOING_GIFT_COOLDOWN_MS) > Date.now()) {
const retryAt = new Date(lastGift.createdAt.getTime() + 3_600_000); const retryAt = new Date(lastGift.createdAt.getTime() + FalukantService.WOOING_GIFT_COOLDOWN_MS);
const err = new PreconditionError('tooOften'); const err = new PreconditionError('tooOften');
err.meta = { retryAt: retryAt.toISOString() }; err.meta = { retryAt: retryAt.toISOString() };
throw err; 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({ const gift = await PromotionalGift.findOne({
where: { id: giftId }, where: { id: giftId },
include: [ include: [
{ {
model: PromotionalGiftCharacterTrait, model: PromotionalGiftCharacterTrait,
as: 'characterTraits', as: 'characterTraits',
where: { traitId: { [Op.in]: user.character.traits.map(t => t.id) } }, where: traitWhere,
required: false required: false
}, },
{ {
@@ -3230,12 +3351,14 @@ class FalukantService extends BaseService {
if (user.money < cost) { if (user.money < cost) {
throw new PreconditionError('insufficientFunds'); 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) { 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) // Finde den höchsten Charakterwert (wie im Frontend). Falls keine Traits vorhanden, 0.
const highestCharacterValue = Math.max(...traits.map(ct => ct.suitability)); const highestCharacterValue = traits.length ? Math.max(...traits.map(ct => ct.suitability)) : 0;
const moodRecord = gift.promotionalgiftmoods[0]; const moodRecord = gift.promotionalgiftmoods[0];
if (!moodRecord) { if (!moodRecord) {
throw new Error('noMoodData'); throw new Error('noMoodData');
@@ -3260,7 +3383,7 @@ class FalukantService extends BaseService {
async checkProposalProgress(relation) { async checkProposalProgress(relation) {
const { nextStepProgress } = relation; const { nextStepProgress } = relation;
if (nextStepProgress >= 100) { if (nextStepProgress >= FalukantService.WOOING_PROGRESS_TARGET) {
const engagedStatus = await RelationshipType.findOne({ where: { tr: 'engaged' } }); const engagedStatus = await RelationshipType.findOne({ where: { tr: 'engaged' } });
await relation.update({ nextStepProgress: 0, relationshipTypeId: engagedStatus.id }); await relation.update({ nextStepProgress: 0, relationshipTypeId: engagedStatus.id });
const user = await User.findOne({ const user = await User.findOne({
@@ -3279,8 +3402,12 @@ class FalukantService extends BaseService {
} }
async getGiftCost(value, titleOfNobility, lowestTitleOfNobility) { async getGiftCost(value, titleOfNobility, lowestTitleOfNobility) {
const titleLevel = titleOfNobility - lowestTitleOfNobility + 1; const val = Number(value) || 0;
return Math.round(value * Math.pow(1 + titleLevel * 0.3, 1.3) * 100) / 100; 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() { async getTitlesOfNobility() {

View File

@@ -2,38 +2,110 @@
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import amqp from 'amqplib/callback_api.js'; 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 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) { export function setupWebSocket(server) {
const io = new Server(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) => { 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) => { connection.on('error', (connectionError) => {
if (err) throw err; 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.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) => { io.on('connection', (socket) => {
console.log('Client connected via WebSocket'); 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) => { socket.on('newMessage', (message) => {
if (channel) {
try {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message))); 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', () => { socket.on('disconnect', () => {
console.log('Client disconnected'); console.log('Client disconnected');
}); });
}); });
});
});
} }

View 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();

View 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();

View 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)}`);
}

View 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

View File

@@ -35,7 +35,7 @@ export function setupWebSocket(server) {
export function getIo() { export function getIo() {
if (!io) { if (!io) {
throw new Error('Socket.io ist nicht initialisiert!'); return null;
} }
return io; return io;
} }
@@ -46,6 +46,10 @@ export function getUserSockets() {
export async function notifyUser(recipientHashedUserId, event, data) { export async function notifyUser(recipientHashedUserId, event, data) {
const io = getIo(); const io = getIo();
if (!io) {
if (DEBUG_SOCKETS) console.warn('[socket.io] notifyUser übersprungen: Socket.io nicht initialisiert');
return;
}
const userSockets = getUserSockets(); const userSockets = getUserSockets();
try { try {
const recipientUser = await baseService.getUserByHashedId(recipientHashedUserId); const recipientUser = await baseService.getUserByHashedId(recipientHashedUserId);
@@ -70,6 +74,10 @@ export async function notifyUser(recipientHashedUserId, event, data) {
export async function notifyAllUsers(event, data) { export async function notifyAllUsers(event, data) {
const io = getIo(); const io = getIo();
if (!io) {
if (DEBUG_SOCKETS) console.warn('[socket.io] notifyAllUsers übersprungen: Socket.io nicht initialisiert');
return;
}
const userSockets = getUserSockets(); const userSockets = getUserSockets();
try { try {

230
docs/UI_REDESIGN_PLAN.md Normal file
View 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
View 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
View 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.

View File

@@ -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_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
VITE_DAEMON_SOCKET=ws://localhost:4551 VITE_DAEMON_SOCKET=ws://127.0.0.1:4551
VITE_CHAT_WS_URL=ws://localhost.de:1235 VITE_CHAT_WS_URL=ws://127.0.0.1:1235

View File

@@ -1,4 +1,5 @@
VITE_API_BASE_URL=https://www.your-part.de VITE_API_BASE_URL=https://www.your-part.de
VITE_PUBLIC_BASE_URL=https://www.your-part.de
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551 VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
VITE_CHAT_WS_URL=wss://www.your-part.de:1235 VITE_CHAT_WS_URL=wss://www.your-part.de:1235

View File

@@ -1,6 +1,6 @@
VITE_API_BASE_URL=https://www.your-part.de VITE_API_BASE_URL=https://www.your-part.de
VITE_PUBLIC_BASE_URL=https://www.your-part.de
VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg
VITE_DAEMON_SOCKET=wss://www.your-part.de:4551 VITE_DAEMON_SOCKET=wss://www.your-part.de:4551
VITE_CHAT_WS_URL=wss://www.your-part.de:1235 VITE_CHAT_WS_URL=wss://www.your-part.de:1235
VITE_SOCKET_IO_URL=https://www.your-part.de:4443 VITE_SOCKET_IO_URL=https://www.your-part.de:4443

View File

@@ -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 BetaPhase und wird laufend erweitert." /> <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 BetaPhase 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="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" /> <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:type" content="website" />
<meta property="og:site_name" content="YourPart" /> <meta property="og:site_name" content="YourPart" />
<meta property="og:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" /> <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: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: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: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: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" /> <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> </head>
<body> <body>
<div id="app"></div> <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> <script type="module" src="/src/main.js"></script>
</body> </body>

View File

@@ -1,6 +1,34 @@
User-agent: * User-agent: *
Allow: / 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 Sitemap: https://www.your-part.de/sitemap.xml

View File

@@ -8,6 +8,11 @@
<url> <url>
<loc>https://www.your-part.de/blogs</loc> <loc>https://www.your-part.de/blogs</loc>
<changefreq>daily</changefreq> <changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://www.your-part.de/vokabeltrainer</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
@@ -16,25 +21,8 @@
<priority>0.8</priority> <priority>0.8</priority>
</url> </url>
<url> <url>
<loc>https://www.your-part.de/socialnetwork/vocab</loc> <loc>https://www.your-part.de/minigames</loc>
<changefreq>weekly</changefreq> <changefreq>weekly</changefreq>
<priority>0.7</priority> <priority>0.7</priority>
</url> </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> </urlset>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div id="app"> <div id="app" class="app-shell">
<AppHeader /> <AppHeader class="app-shell__header" />
<AppNavigation v-if="isLoggedIn && user.active" /> <AppNavigation v-if="isLoggedIn && user.active" class="app-shell__nav" />
<AppContent /> <AppContent class="app-shell__content" />
<AppFooter /> <AppFooter />
<AnswerContact ref="answerContactDialog" /> <AnswerContact ref="answerContactDialog" />
<RandomChatDialog ref="randomChatDialog" /> <RandomChatDialog ref="randomChatDialog" />
@@ -42,9 +42,6 @@ import MultiChatDialog from './dialogues/chat/MultiChatDialog.vue';
export default { export default {
name: 'App', name: 'App',
mounted() {
document.title = 'yourPart';
},
computed: { computed: {
...mapGetters(['isLoggedIn', 'user']) ...mapGetters(['isLoggedIn', 'user'])
}, },
@@ -74,10 +71,10 @@ export default {
</script> </script>
<style> <style>
#app { .app-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100vh;
overflow: hidden; overflow: hidden;
} }
</style> </style>

View File

@@ -4,3 +4,17 @@ export const fetchPublicRooms = async () => {
const response = await apiClient.get("/api/chat/rooms"); const response = await apiClient.get("/api/chat/rooms");
return response.data; // expecting array of { id, title, ... } 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}`);
};

View File

@@ -1,50 +1,355 @@
:root { :root {
/* Moderne Farbpalette für bessere Lesbarkeit */ color-scheme: light;
--color-primary-orange: #FFB84D; /* Gelbliches, sanftes Orange */
--color-primary-orange-hover: #FFC966; /* Noch helleres gelbliches Orange für Hover */ --font-display: "Trebuchet MS", "Segoe UI", sans-serif;
--color-primary-orange-light: #FFF8E1; /* Sehr helles gelbliches Orange für Hover-Hintergründe */ --font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--color-primary-green: #4ADE80; /* Helles, freundliches Grün - passt zum Orange */
--color-primary-green-hover: #6EE7B7; /* Noch helleres Grün für Hover */ --color-bg: #f4f1ea;
--color-text-primary: #1F2937; /* Dunkles Grau für Haupttext (bessere Lesbarkeit) */ --color-bg-elevated: #faf7f1;
--color-text-secondary: #5D4037; /* Dunkles Braun für sekundären Text/Hover */ --color-bg-muted: #f5eee2;
--color-text-on-orange: #000000; /* Schwarz auf Orange */ --color-surface: rgba(255, 251, 246, 0.94);
--color-text-on-green: #000000; /* Schwarz auf Grün */ --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, html,
body { body,
#app {
height: 100%; 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 { body {
font-family: Arial, sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #f4f4f4; font-family: var(--font-body);
color: var(--color-text-primary); color: var(--color-text-primary);
background: transparent;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
} }
a { a {
text-decoration: none;
color: inherit; color: inherit;
text-decoration: none;
transition: color var(--transition-fast);
} }
button { a:hover {
margin-left: 10px; color: var(--color-primary);
padding: 5px 12px; }
cursor: pointer;
background: var(--color-primary-orange); button,
color: var(--color-text-on-orange); input,
border: 1px solid var(--color-primary-orange); select,
border-radius: 4px; textarea {
transition: background 0.05s; 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: 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 { button:hover,
background: var(--color-primary-orange-light); .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); 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 { .rc-system {
@@ -52,25 +357,13 @@ button:hover {
} }
.rc-self { .rc-self {
color: #ff0000; color: #c0412c;
font-weight: bold; font-weight: 700;
} }
.rc-partner { .rc-partner {
color: #0000ff; color: #2357b5;
font-weight: bold; font-weight: 700;
}
.link {
color: var(--color-primary-orange);
cursor: pointer;
}
h1,
h2,
h3 {
margin: 0;
display: block;
} }
.multiselect__option--highlight, .multiselect__option--highlight,
@@ -80,61 +373,65 @@ h3 {
.multiselect__option--highlight[data-selected], .multiselect__option--highlight[data-selected],
.multiselect__option--highlight[data-deselect] { .multiselect__option--highlight[data-deselect] {
background: none; background: none;
background-color: var(--color-primary-orange); background-color: var(--color-primary);
color: var(--color-text-on-orange); color: var(--color-text-on-accent);
}
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);
} }
.font-color-gender-male { .font-color-gender-male {
color: #1E90FF; color: #1e90ff;
} }
.font-color-gender-female { .font-color-gender-female {
color: #FF69B4; color: #d14682;
} }
.font-color-gender-transmale { .font-color-gender-transmale {
color: #00CED1; color: #1f8b9b;
} }
.font-color-gender-transfemale { .font-color-gender-transfemale {
color: #FFB6C1; color: #d78398;
} }
.font-color-gender-nonbinary { .font-color-gender-nonbinary {
color: #DAA520; color: #ba7c1f;
} }
main, @media (max-width: 960px) {
.contenthidden { :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%; width: 100%;
height: 100%; overflow-x: auto;
overflow: hidden; white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
} }
.contentscroll {
width: 100%; @media (prefers-reduced-motion: reduce) {
height: 100%; html:focus-within {
overflow: auto; scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
} }

View File

@@ -1,26 +1,51 @@
<template> <template>
<main class="contenthidden"> <main class="app-content contenthidden">
<div class="contentscroll"> <div class="app-content__scroll contentscroll">
<div class="app-content__inner">
<AppSectionBar />
<router-view></router-view> <router-view></router-view>
</div> </div>
</div>
</main> </main>
</template> </template>
<script> <script>
import AppSectionBar from './AppSectionBar.vue';
export default { export default {
name: 'AppContent' name: 'AppContent',
components: {
AppSectionBar
}
}; };
</script> </script>
<style scoped> <style scoped>
main { .app-content {
padding: 0;
background-color: #ffffff;
flex: 1; flex: 1;
} height: auto;
min-height: 0;
padding: 0;
overflow: hidden;
}
.contentscroll { .app-content__scroll {
padding: 20px; background: transparent;
} height: 100%;
</style> 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>

View File

@@ -1,19 +1,37 @@
<template> <template>
<footer> <footer class="app-footer">
<div class="logo" @click="showFalukantDaemonStatus"><img src="/images/icons/logo_color.png"></div> <div class="app-footer__inner">
<div class="window-bar"> <div class="footer-system">
<button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button" <button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle"> <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" /> <img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) : <span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span> dialog.dialog.localTitle }}</span>
</button> </button>
<span v-if="openDialogs.length === 0" class="window-bar__empty">System bereit</span>
</div> </div>
<div class="static-block"> <div class="static-block">
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a> <a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a> <a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
<a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a> <a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a>
</div> </div>
</div>
</footer> </footer>
</template> </template>
@@ -63,18 +81,59 @@ export default {
</script> </script>
<style scoped> <style scoped>
footer { .app-footer {
display: flex; flex: 0 0 auto;
background-color: var(--color-primary-green); padding: 0;
height: 38px;
width: 100%;
color: #1F2937; /* Dunkles Grau für besseren Kontrast auf hellem Grün */
} }
.logo, .app-footer__inner {
.window-bar, max-width: var(--shell-max-width);
.static-block { margin: 0 auto;
text-align: center; 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 { .window-bar {
@@ -83,24 +142,39 @@ footer {
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 10px; 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 { .dialog-button {
max-width: 12em; max-width: 15em;
min-height: 30px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 5px 10px; padding: 0 12px;
border: none; border-radius: 999px;
border-radius: 4px; background: rgba(255, 255, 255, 0.7);
cursor: pointer; color: var(--color-text-primary);
display: flex; border: 1px solid rgba(120, 195, 138, 0.18);
align-items: center; box-shadow: none;
background: none; }
height: 1.8em;
border: 1px solid #0a4337; .dialog-button:hover {
box-shadow: 1px 1px 2px #484949; background: rgba(255, 255, 255, 0.92);
} }
.dialog-button>img { .dialog-button>img {
@@ -111,16 +185,71 @@ footer {
margin-left: 5px; margin-left: 5px;
} }
.logo>img {
width: 36px;
height: 36px;
}
.static-block { .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 { .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> </style>

View File

@@ -1,15 +1,27 @@
<template> <template>
<header> <header class="app-header">
<div class="logo"><img src="/images/logos/logo.png" /></div> <div class="app-header__inner">
<div class="advertisement">Advertisement</div> <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="connection-status" v-if="isLoggedIn">
<div class="status-indicator" :class="backendStatusClass"> <div class="status-indicator" :class="backendStatusClass">
<span class="status-dot"></span> <span class="status-dot"></span>
<span class="status-text">B</span> <span class="status-text">Backend</span>
</div> </div>
<div class="status-indicator" :class="daemonStatusClass"> <div class="status-indicator" :class="daemonStatusClass">
<span class="status-dot"></span> <span class="status-dot"></span>
<span class="status-text">D</span> <span class="status-text">Daemon</span>
</div>
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -43,43 +55,118 @@ export default {
</script> </script>
<style scoped> <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; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 10px; gap: 16px;
background-color: #f8a22b;
} }
.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 { .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 { .connection-status {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: 10px; gap: 8px;
gap: 5px;
} }
.status-indicator { .status-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 2px 6px; gap: 8px;
border-radius: 4px; min-height: 28px;
font-size: 6pt; padding: 0 10px;
font-weight: 500; 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 { .status-dot {
width: 6px; width: 8px;
height: 6px; height: 8px;
border-radius: 50%; border-radius: 50%;
margin-right: 4px;
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
@@ -100,23 +187,23 @@ header {
} }
.status-connected { .status-connected {
background-color: rgba(76, 175, 80, 0.1); background-color: rgba(76, 175, 80, 0.12);
color: #2e7d32; color: #245b2c;
} }
.status-connecting { .status-connecting {
background-color: rgba(255, 152, 0, 0.1); background-color: rgba(255, 152, 0, 0.12);
color: #f57c00; color: #8b5e0d;
} }
.status-disconnected { .status-disconnected {
background-color: rgba(244, 67, 54, 0.1); background-color: rgba(244, 67, 54, 0.12);
color: #d32f2f; color: #8f2c27;
} }
.status-error { .status-error {
background-color: rgba(244, 67, 54, 0.1); background-color: rgba(244, 67, 54, 0.12);
color: #d32f2f; color: #8f2c27;
} }
@keyframes pulse { @keyframes pulse {
@@ -124,4 +211,53 @@ header {
50% { opacity: 0.5; } 50% { opacity: 0.5; }
100% { opacity: 1; } 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> </style>

View File

@@ -1,26 +1,44 @@
<template> <template>
<nav> <nav
ref="navRoot"
class="app-navigation"
:class="{ 'app-navigation--suppress-hover': suppressHover }"
>
<div class="nav-primary">
<ul> <ul>
<!-- Hauptmenü --> <!-- Hauptmenü -->
<li <li
v-for="(item, key) in menu" v-for="(item, key) in menu"
:key="key" :key="key"
class="mainmenuitem" 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 <span
v-if="item.icon" v-if="item.icon"
:style="`background-image:url('/images/icons/${item.icon}')`" :style="`background-image:url('/images/icons/${item.icon}')`"
class="menu-icon" class="menu-icon"
>&nbsp;</span> >&nbsp;</span>
<span>{{ $t(`navigation.${key}`) }}</span> <span class="mainmenuitem__label">{{ $t(`navigation.${key}`) }}</span>
<span v-if="hasTopLevelSubmenu(item)" class="mainmenuitem__caret">&#x25BE;</span>
<!-- Untermenü Ebene 1 --> <!-- Untermenü Ebene 1 -->
<ul v-if="item.children" class="submenu1"> <ul v-if="hasTopLevelSubmenu(item)" class="submenu1" :class="{ 'submenu1--open': isMainExpanded(key) }">
<li <li
v-for="(subitem, subkey) in item.children" v-for="(subitem, subkey) in item.children"
:key="subkey" :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 <span
v-if="subitem.icon" v-if="subitem.icon"
@@ -29,7 +47,7 @@
>&nbsp;</span> >&nbsp;</span>
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span> <span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span <span
v-if="subkey === 'forum' || subkey === 'vocabtrainer' || subitem.children" v-if="hasSecondLevelSubmenu(subitem, subkey)"
class="subsubmenu" class="subsubmenu"
>&#x25B6;</span> >&#x25B6;</span>
@@ -37,11 +55,16 @@
<ul <ul
v-if="subkey === 'forum' && forumList.length" v-if="subkey === 'forum' && forumList.length"
class="submenu2" class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
> >
<li <li
v-for="forum in forumList" v-for="forum in forumList"
:key="forum.id" :key="forum.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openForum', params: forum.id }, $event)" @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 }} {{ forum.name }}
</li> </li>
@@ -51,16 +74,25 @@
<ul <ul
v-else-if="subkey === 'vocabtrainer' && vocabLanguagesList.length" v-else-if="subkey === 'vocabtrainer' && vocabLanguagesList.length"
class="submenu2" class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
> >
<li <li
tabindex="0"
role="menuitem"
@click="handleItem({ path: '/socialnetwork/vocab/new' }, $event)" @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') }} {{ $t('navigation.m-sprachenlernen.m-vocabtrainer.newLanguage') }}
</li> </li>
<li <li
v-for="lang in vocabLanguagesList" v-for="lang in vocabLanguagesList"
:key="lang.id" :key="lang.id"
tabindex="0"
role="menuitem"
@click="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)" @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 }} {{ lang.name }}
</li> </li>
@@ -70,11 +102,16 @@
<ul <ul
v-else-if="subitem.children" v-else-if="subitem.children"
class="submenu2" class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
> >
<li <li
v-for="(subsubitem, subsubkey) in subitem.children" v-for="(subsubitem, subsubkey) in subitem.children"
:key="subsubkey" :key="subsubkey"
tabindex="0"
role="menuitem"
@click="handleItem(subsubitem, $event)" @click="handleItem(subsubitem, $event)"
@keydown.enter.prevent="handleItem(subsubitem, $event)"
@keydown.space.prevent="handleItem(subsubitem, $event)"
> >
<span <span
v-if="subsubitem.icon" v-if="subsubitem.icon"
@@ -91,17 +128,29 @@
v-if="item.showLoggedinFriends === 1 && friendsList.length" v-if="item.showLoggedinFriends === 1 && friendsList.length"
v-for="friend in friendsList" v-for="friend in friendsList"
:key="friend.id" :key="friend.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)" @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 }} {{ friend.username }}
<ul class="submenu2"> <ul class="submenu2">
<li <li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)" @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') }} {{ $t('navigation.m-friends.chat') }}
</li> </li>
<li <li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openProfile', params: friend.id }, $event)" @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') }} {{ $t('navigation.m-friends.profile') }}
</li> </li>
@@ -110,12 +159,13 @@
</ul> </ul>
</li> </li>
</ul> </ul>
</div>
<div class="right-block"> <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="logoutblock">
<span class="username">{{ user.username }}</span> <span class="username">{{ user.username }}</span>
<span @click="logout" class="menuitem"> <span class="menuitem" @click="logout">
{{ $t('navigation.logout') }} {{ $t('navigation.logout') }}
</span> </span>
</span> </span>
@@ -127,6 +177,7 @@
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import { createApp } from 'vue'; import { createApp } from 'vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { EventBus } from '@/utils/eventBus.js';
import RandomChatDialog from '../dialogues/chat/RandomChatDialog.vue'; import RandomChatDialog from '../dialogues/chat/RandomChatDialog.vue';
import MultiChatDialog from '../dialogues/chat/MultiChatDialog.vue'; import MultiChatDialog from '../dialogues/chat/MultiChatDialog.vue';
@@ -146,7 +197,14 @@ export default {
return { return {
forumList: [], forumList: [],
friendsList: [], friendsList: [],
vocabLanguagesList: [] vocabLanguagesList: [],
expandedMainKey: null,
expandedSubKey: null,
pinnedMainKey: null,
pinnedSubKey: null,
suppressHover: false,
hoverReleaseTimer: null,
isMobileNav: false
}; };
}, },
computed: { computed: {
@@ -156,6 +214,9 @@ export default {
menuNeedsUpdate(newVal) { menuNeedsUpdate(newVal) {
if (newVal) this.loadMenu(); if (newVal) this.loadMenu();
}, },
$route() {
this.collapseMenus();
},
socket(newSocket) { socket(newSocket) {
if (newSocket) { if (newSocket) {
newSocket.on('forumschanged', this.fetchForums); newSocket.on('forumschanged', this.fetchForums);
@@ -171,6 +232,10 @@ export default {
this.fetchFriends(); this.fetchFriends();
this.fetchVocabLanguages(); this.fetchVocabLanguages();
} }
this.updateViewportState();
window.addEventListener('resize', this.updateViewportState);
document.addEventListener('click', this.handleDocumentClick);
document.addEventListener('keydown', this.handleDocumentKeydown);
}, },
beforeUnmount() { beforeUnmount() {
const sock = this.socket; const sock = this.socket;
@@ -179,10 +244,128 @@ export default {
sock.off('friendloginchanged'); sock.off('friendloginchanged');
sock.off('reloadmenu'); 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: { methods: {
...mapActions(['loadMenu', 'logout']), ...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() { openMultiChat() {
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel: // Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
const exampleRooms = [ 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() { async fetchForums() {
try { try {
const res = await apiClient.get('/api/forum'); const res = await apiClient.get('/api/forum');
@@ -250,11 +448,19 @@ export default {
* 3) Bei `action`: custom action aufrufen * 3) Bei `action`: custom action aufrufen
* 4) Sonst: normale Router-Navigation * 4) Sonst: normale Router-Navigation
*/ */
handleItem(item, event) { handleItem(item, event, key = null) {
event.stopPropagation(); event.stopPropagation();
// 1) nur aufklappen, wenn es echte Untermenüs gibt (nicht bei leerem children wie bei Startseite) if (key && this.hasTopLevelSubmenu(item)) {
if (item.children && Object.keys(item.children).length > 0) return; if (this.isMobileNav) {
this.toggleMain(key);
} else {
this.togglePinnedMain(key);
}
return;
}
if (this.hasChildren(item)) return;
// 2) view → Dialog/Window // 2) view → Dialog/Window
if (item.view) { if (item.view) {
@@ -271,18 +477,38 @@ export default {
} else { } else {
console.error(`Dialog '${item.class}' gefunden, aber keine open()-Methode verfügbar.`); console.error(`Dialog '${item.class}' gefunden, aber keine open()-Methode verfügbar.`);
} }
this.collapseMenus();
return; return;
} }
// 3) custom action (openForum, openChat, ...) // 3) custom action (openForum, openChat, ...)
if (item.action && typeof this[item.action] === 'function') { 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) StandardNavigation // 4) StandardNavigation
if (item.path) { if (item.path) {
this.$router.push(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> <style lang="scss" scoped>
@import '../assets/styles.scss'; @import '../assets/styles.scss';
nav, .app-navigation,
nav > ul { .nav-primary > ul {
display: flex; display: flex;
justify-content: space-between;
background-color: #f8a22b;
color: #000;
padding: 0; padding: 0;
margin: 0; margin: 0;
cursor: pointer;
flex-direction: row; 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; 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 { ul {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
nav > ul > li { .mainmenuitem {
padding: 0 1em; display: flex;
line-height: 2.5em; align-items: center;
transition: background-color 0.25s; 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 { .mainmenuitem:focus-visible,
background-color: #f8a22b; .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; white-space: nowrap;
} }
nav > ul > li:hover > span { .mainmenuitem__caret {
color: #000; margin-left: 6px;
} font-size: 0.7rem;
color: rgba(95, 75, 57, 0.7);
nav > ul > li:hover > ul {
display: inline-block;
} }
a { a {
@@ -335,17 +624,29 @@ a {
.right-block { .right-block {
display: flex; 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 { .logoutblock {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end;
gap: 2px;
} }
.menuitem { .menuitem {
cursor: pointer; cursor: pointer;
color: #5D4037; color: var(--color-primary);
font-weight: 700;
} }
.mailbox { .mailbox {
@@ -353,20 +654,29 @@ a {
background-size: contain; background-size: contain;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; background-position: center;
padding-left: 24px; width: 38px;
text-align: left; 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 { .mainmenuitem { position: relative; font-weight: 700; }
position: relative;
}
.submenu1 { .submenu1 {
position: absolute; position: absolute;
border: 1px solid #5D4037; display: block;
background-color: #f8a22b; border: 1px solid rgba(93, 64, 55, 0.12);
background: rgba(255, 252, 247, 0.99);
left: 0; 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; max-height: 0;
overflow: visible; overflow: visible;
opacity: 0; opacity: 0;
@@ -385,16 +695,27 @@ a {
visibility 0s; 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 { .submenu1 > li {
padding: 0.5em; display: block;
line-height: 1em; padding: 0.75em 0.9em;
color: #5D4037; line-height: 1.1em;
color: var(--color-text-secondary);
position: relative; position: relative;
border-radius: 14px;
} }
.submenu1 > li:hover { .submenu1 > li:hover {
color: #000; color: var(--color-text-primary);
background-color: #f8a22b; background-color: rgba(248, 162, 43, 0.12);
} }
.menu-icon, .menu-icon,
@@ -407,7 +728,7 @@ a {
.menu-icon { .menu-icon {
width: 24px; width: 24px;
height: 24px; height: 24px;
margin-right: 3px; margin-right: 8px;
} }
.submenu-icon { .submenu-icon {
@@ -419,10 +740,15 @@ a {
.submenu2 { .submenu2 {
position: absolute; position: absolute;
background-color: #f8a22b; display: block;
left: 100%; background: rgba(255, 252, 247, 0.98);
left: calc(100% + 8px);
top: 0; 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; max-height: 0;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;
@@ -441,15 +767,43 @@ a {
visibility 0s; 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 { .submenu2 > li {
padding: 0.5em; padding: 0.75em 0.9em;
line-height: 1em; line-height: 1em;
color: #5D4037; color: var(--color-text-secondary);
border-radius: 14px;
} }
.submenu2 > li:hover { .submenu2 > li:hover {
color: #000; color: var(--color-text-primary);
background-color: #f8a22b; 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 { .subsubmenu {
@@ -457,4 +811,103 @@ a {
font-size: 8pt; font-size: 8pt;
margin-right: -4px; 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> </style>

View 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>

View File

@@ -1,16 +1,38 @@
<template> <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> </template>
<script> <script>
import { markRaw } from 'vue'; 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'; import { getApiBaseURL } from '@/utils/axios.js';
/** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */ /** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */
const MODELS_API_PATH = '/api/models/3d/falukant/characters'; 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 { export default {
name: 'Character3D', name: 'Character3D',
@@ -40,8 +62,10 @@ export default {
model: null, model: null,
animationId: null, animationId: null,
mixer: null, mixer: null,
clock: markRaw(new THREE.Clock()), clock: null,
baseYPosition: 0 // Basis-Y-Position für Animation baseYPosition: 0,
showFallback: false,
threeRuntime: null
}; };
}, },
computed: { computed: {
@@ -93,34 +117,50 @@ export default {
const base = getApiBaseURL(); const base = getApiBaseURL();
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH; const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
return `${prefix}/${this.actualGender}_${age}y.glb`; return `${prefix}/${this.actualGender}_${age}y.glb`;
},
fallbackImageSrc() {
return this.actualGender === 'female'
? '/images/mascot/mascot_female.png'
: '/images/mascot/mascot_male.png';
} }
}, },
watch: { watch: {
actualGender() { async actualGender() {
this.loadModel(); await this.loadModel();
}, },
ageGroup() { async ageGroup() {
this.loadModel(); await this.loadModel();
} }
}, },
mounted() { async mounted() {
this.init3D(); await this.init3D();
this.loadModel(); await this.loadModel();
this.animate(); this.animate();
}, },
beforeUnmount() { beforeUnmount() {
this.cleanup(); this.cleanup();
}, },
methods: { methods: {
init3D() { async ensureThreeRuntime() {
if (!this.threeRuntime) {
this.threeRuntime = markRaw(await loadThreeRuntime());
}
return this.threeRuntime;
},
async init3D() {
const container = this.$refs.container; const container = this.$refs.container;
if (!container) return; 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 // Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
this.scene = markRaw(new THREE.Scene()); this.scene = markRaw(new THREE.Scene());
if (!this.noBackground) { if (!this.noBackground) {
this.scene.background = new THREE.Color(0xf0f0f0); this.scene.background = new THREE.Color(0xf0f0f0);
this.loadBackground(); await this.loadBackground();
} }
// Camera erstellen // Camera erstellen
@@ -159,7 +199,8 @@ export default {
window.addEventListener('resize', this.onWindowResize); window.addEventListener('resize', this.onWindowResize);
}, },
loadBackground() { async loadBackground() {
const { THREE } = await this.ensureThreeRuntime();
// Optimierte Versionen (512×341, ~130 KB); Originale ~3 MB // Optimierte Versionen (512×341, ~130 KB); Originale ~3 MB
const backgrounds = ['bg1_opt.png', 'bg2_opt.png']; const backgrounds = ['bg1_opt.png', 'bg2_opt.png'];
const randomBg = backgrounds[Math.floor(Math.random() * backgrounds.length)]; const randomBg = backgrounds[Math.floor(Math.random() * backgrounds.length)];
@@ -187,6 +228,7 @@ export default {
async loadModel() { async loadModel() {
if (!this.scene) return; if (!this.scene) return;
const { THREE, GLTFLoader, DRACOLoader } = await this.ensureThreeRuntime();
// Altes Modell entfernen // Altes Modell entfernen
if (this.model) { if (this.model) {
@@ -301,12 +343,17 @@ export default {
} }
} catch (error) { } catch (error) {
console.error('Error loading 3D model:', error); console.error('Error loading 3D model:', error);
this.showFallback = true;
} }
}, },
animate() { animate() {
this.animationId = requestAnimationFrame(this.animate); this.animationId = requestAnimationFrame(this.animate);
if (!this.clock) {
return;
}
const delta = this.clock.getDelta(); const delta = this.clock.getDelta();
// Animation-Mixer aktualisieren // Animation-Mixer aktualisieren
@@ -375,10 +422,25 @@ export default {
</script> </script>
<style scoped> <style scoped>
.character-3d-shell {
width: 100%;
height: 100%;
min-height: 0;
position: relative;
}
.character-3d-container { .character-3d-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.character-fallback {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center bottom;
}
</style> </style>

View File

@@ -16,7 +16,7 @@
<slot></slot> <slot></slot>
</div> </div>
<div class="dialog-footer"> <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 }} {{ isTitleTranslated ? $t(button.text) : button.text }}
</button> </button>
</div> </div>
@@ -142,6 +142,9 @@ export default {
return this.minimized; return this.minimized;
}, },
startDragging(event) { startDragging(event) {
if (window.innerWidth <= 760) {
return;
}
this.isDragging = true; this.isDragging = true;
const dialog = this.$refs.dialog; const dialog = this.$refs.dialog;
this.dragOffsetX = event.clientX - dialog.offsetLeft; this.dragOffsetX = event.clientX - dialog.offsetLeft;
@@ -186,7 +189,8 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; 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 { .dialog-overlay.non-modal {
@@ -195,14 +199,17 @@ export default {
} }
.dialog { .dialog {
background: white; background:
linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-medium);
border-radius: 8px; border-radius: var(--radius-lg);
border: 1px solid rgba(93, 64, 55, 0.12);
pointer-events: all; pointer-events: all;
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
overflow: hidden;
} }
.dialog.minimized { .dialog.minimized {
@@ -214,64 +221,112 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 5px 5px; gap: 10px;
border-bottom: 1px solid #ddd; padding: 12px 16px;
background-color: var(--color-primary-orange); 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; cursor: move;
} }
.dialog-icon { .dialog-icon {
padding: 2px 5px 0 0; padding: 2px 6px 0 0;
}
.dialog-icon img {
width: 20px;
height: 20px;
object-fit: contain;
} }
.dialog-title { .dialog-title {
flex-grow: 1; flex-grow: 1;
font-size: 1.5em; font-size: 1.08rem;
font-weight: bold; font-weight: 800;
color: var(--color-text-primary);
} }
.dialog-close, .dialog-close,
.dialog-minimize { .dialog-minimize {
cursor: pointer; cursor: pointer;
font-size: 1.5em; font-size: 1.1rem;
margin-left: 10px; 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 { .dialog-body {
flex-grow: 1; flex-grow: 1;
padding: 20px; padding: 18px 20px;
overflow-y: auto; overflow-y: auto;
display: var(--dialog-display); display: var(--dialog-display);
color: var(--color-text-primary);
&[style*="--dialog-display: flex"] { &[style*="--dialog-display: flex"] {
flex-direction: column; flex-direction: column;
} }
} }
dialog-footer { .dialog-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 10px 20px; gap: 10px;
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 { .dialog-button {
margin-left: 10px; margin-left: 0;
padding: 5px 10px; min-height: 38px;
cursor: pointer;
background: var(--color-primary-orange);
color: #000000;
border: none;
border-radius: 4px;
transition: background 0.02s;
} }
.dialog-button:hover { .dialog-button:hover {
background: #FFF4F0; color: #2b1f14;
color: #5D4037;
border: 1px solid #5D4037;
} }
.is-active { .is-active {
z-index: 1100; 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> </style>

View File

@@ -105,7 +105,8 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; 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 { .dialog-overlay.non-modal {
@@ -114,12 +115,14 @@ export default {
} }
.dialog { .dialog {
background: white; background: linear-gradient(180deg, rgba(255, 252, 247, 0.98) 0%, rgba(249, 242, 232, 0.98) 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); box-shadow: var(--shadow-medium);
border-radius: 8px; border-radius: var(--radius-lg);
border: 1px solid rgba(93, 64, 55, 0.12);
pointer-events: all; pointer-events: all;
overflow: hidden;
} }
.dialog.minimized { .dialog.minimized {
@@ -131,9 +134,9 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 10px 20px; padding: 12px 16px;
border-bottom: 1px solid #ddd; border-bottom: 1px solid rgba(93, 64, 55, 0.1);
background-color: var(--color-primary-orange); background: linear-gradient(180deg, rgba(248, 162, 43, 0.16) 0%, rgba(255, 255, 255, 0.56) 100%);
} }
.dialog-icon { .dialog-icon {
@@ -142,42 +145,46 @@ export default {
.dialog-title { .dialog-title {
flex-grow: 1; flex-grow: 1;
font-size: 1.5em; font-size: 1.08rem;
font-weight: bold; font-weight: 800;
color: var(--color-text-primary);
} }
.dialog-close, .dialog-close,
.dialog-minimize { .dialog-minimize {
cursor: pointer; cursor: pointer;
font-size: 1.5em; font-size: 1.1rem;
margin-left: 10px; 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 { .dialog-body {
flex-grow: 1; flex-grow: 1;
padding: 20px; padding: 18px 20px;
overflow-y: auto; overflow-y: auto;
color: var(--color-text-primary);
} }
.dialog-footer { .dialog-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 10px 20px; padding: 14px 20px 18px;
border-top: 1px solid #ddd; border-top: 1px solid rgba(93, 64, 55, 0.08);
background: rgba(255, 255, 255, 0.46);
} }
.dialog-button { .dialog-button {
margin-left: 10px; margin-left: 0;
padding: 10px 20px; min-height: 38px;
cursor: pointer;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
transition: background 0.3s;
} }
.dialog-button:hover { .dialog-button:hover {
background: #0056b3; color: #2b1f14;
} }
</style> </style>

View File

@@ -85,6 +85,7 @@ import InputNumberWidget from '@/components/form/InputNumberWidget.vue';
import FloatInputWidget from '@/components/form/FloatInputWidget.vue'; import FloatInputWidget from '@/components/form/FloatInputWidget.vue';
import CheckboxWidget from '@/components/form/CheckboxWidget.vue'; import CheckboxWidget from '@/components/form/CheckboxWidget.vue';
import MultiselectWidget from '@/components/form/MultiselectWidget.vue'; import MultiselectWidget from '@/components/form/MultiselectWidget.vue';
import { showApiError, showError } from '@/utils/feedback.js';
export default { export default {
name: "SettingsWidget", name: "SettingsWidget",
@@ -158,7 +159,7 @@ export default {
// Prüfe ob das Setting unveränderlich ist // Prüfe ob das Setting unveränderlich ist
const setting = this.settings.find(s => s.id === settingId); const setting = this.settings.find(s => s.id === settingId);
if (setting && setting.immutable && setting.value) { if (setting && setting.immutable && setting.value) {
alert(this.$t('settings.immutable.tooltip')); showError(this, this.$t('settings.immutable.tooltip'));
return; return;
} }
@@ -172,9 +173,7 @@ export default {
this.fetchSettings(); this.fetchSettings();
} catch (err) { } catch (err) {
console.error('Error updating setting:', err); console.error('Error updating setting:', err);
if (err.response && err.response.data && err.response.data.error) { showApiError(this, err, 'Aenderung konnte nicht gespeichert werden.');
alert(err.response.data.error);
}
} }
}, },
languagesList() { languagesList() {
@@ -208,6 +207,7 @@ export default {
}); });
} catch (err) { } catch (err) {
console.error('Error updating visibility:', err); console.error('Error updating visibility:', err);
showApiError(this, err, 'Sichtbarkeit konnte nicht aktualisiert werden.');
} }
}, },
openContactDialog() { openContactDialog() {

View File

@@ -23,11 +23,19 @@
<img :src="'/images/icons/falukant/relationship-' + item.image + '.png'" class="relationship-icon" :title="$t(`falukant.statusbar.${item.key}`)" /> <img :src="'/images/icons/falukant/relationship-' + item.image + '.png'" class="relationship-icon" :title="$t(`falukant.statusbar.${item.key}`)" />
</div> </div>
</template> </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" > <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> </template>
</span> </div>
<MessagesDialog ref="msgs" /> <MessagesDialog ref="msgs" />
</div> </div>
</template> </template>
@@ -35,6 +43,7 @@
<script> <script>
import { mapState, mapGetters } from "vuex"; import { mapState, mapGetters } from "vuex";
import apiClient from "@/utils/axios.js"; import apiClient from "@/utils/axios.js";
import { EventBus } from '@/utils/eventBus.js';
import MessagesDialog from './MessagesDialog.vue'; import MessagesDialog from './MessagesDialog.vue';
export default { export default {
@@ -86,10 +95,12 @@ export default {
// Socket.IO (Backend notifyUser) Hauptkanal für Falukant-Events // Socket.IO (Backend notifyUser) Hauptkanal für Falukant-Events
this.setupSocketListeners(); this.setupSocketListeners();
this.setupDaemonListeners(); this.setupDaemonListeners();
EventBus.on('open-falukant-messages', this.openMessages);
}, },
beforeUnmount() { beforeUnmount() {
this.teardownSocketListeners(); this.teardownSocketListeners();
this.teardownDaemonListeners(); this.teardownDaemonListeners();
EventBus.off('open-falukant-messages', this.openMessages);
}, },
methods: { methods: {
preloadQuickAccessImages() { preloadQuickAccessImages() {
@@ -220,14 +231,21 @@ export default {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: #f4f4f4; flex-wrap: wrap;
border: 1px solid #ccc; background:
border-radius: 4px; linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(247, 238, 224, 0.98) 100%);
width: calc(100% + 40px); border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 1.2em; gap: 1.2em;
margin: -21px -20px 1.5em -20px; padding: 0.55rem 0.9rem;
position: fixed; margin: 0 0 1.5em 0;
position: sticky;
top: 0;
z-index: 100; z-index: 100;
box-shadow: var(--shadow-soft);
} }
.status-item { .status-item {
@@ -235,6 +253,19 @@ export default {
cursor: pointer; cursor: pointer;
display: inline-flex; display: inline-flex;
align-items: center; 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 { .status-icon-wrapper {
@@ -254,6 +285,8 @@ export default {
.menu-icon { .menu-icon {
width: 30px; width: 30px;
height: 30px; height: 30px;
display: block;
flex: 0 0 auto;
cursor: pointer; cursor: pointer;
padding: 4px 2px 0 0; padding: 4px 2px 0 0;
} }

View File

@@ -24,8 +24,31 @@ export default {
}, },
computed: { computed: {
falukantData() { falukantData() {
const d = this.data; // normalize incoming API payload: accept both camelCase and snake_case
if (d && typeof d === 'object' && 'characterName' in d && 'money' in d) return d; 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; return null;
}, },
falukantDisplayName() { falukantDisplayName() {
@@ -46,8 +69,8 @@ export default {
if (g == null || g === '') return '—'; if (g == null || g === '') return '—';
// Altersabhängige, (auf Wunsch) altertümlichere Bezeichnungen // Altersabhängige, (auf Wunsch) altertümlichere Bezeichnungen
const age = Number(this.falukantData?.age); const years = this._ageYearsFromWidgetValue(this.falukantData?.age);
const group = this._getAgeGroupKey(age); const group = years == null ? null : this._getAgeGroupKey(years);
if (group && (g === 'female' || g === 'male')) { if (group && (g === 'female' || g === 'male')) {
const key = `falukant.genderAge.${g}.${group}`; const key = `falukant.genderAge.${g}.${group}`;
const t = this.$t(key); const t = this.$t(key);
@@ -62,18 +85,37 @@ export default {
falukantAgeLabel() { falukantAgeLabel() {
const ageValue = this.falukantData?.age; const ageValue = this.falukantData?.age;
if (ageValue == null) return '—'; if (ageValue == null) return '—';
const numAge = Number(ageValue); const years = this._ageYearsFromWidgetValue(ageValue);
return `${numAge} ${this.$t('falukant.overview.metadata.years')}`; if (years == null) return '—';
return `${years} ${this.$t('falukant.overview.metadata.years')}`;
} }
}, },
methods: { 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) { _getAgeGroupKey(age) {
const a = Number(age); const a = Number(age);
if (Number.isNaN(a)) return null; if (Number.isNaN(a)) return null;
// Pro Sprache konfigurierbare Schwellenwerte aus i18n. // Pro Sprache konfigurierbare Schwellenwerte aus i18n.
// Format: "key:maxAge|key2:maxAge2|..." (maxAge exklusiv, letzte Gruppe sollte hoch gesetzt sein) // 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 parsed = typeof raw === 'string' ? raw : '';
const rules = parsed.split('|') const rules = parsed.split('|')
.map(part => part.trim()) .map(part => part.trim())

View File

@@ -1,7 +1,12 @@
<template> <template>
<DialogWidget ref="dialog" title="passwordReset.title" :isTitleTranslated="true" :show-close=true :buttons="buttons" @close="closeDialog" @reset="resetPassword" name="PasswordReset"> <DialogWidget ref="dialog" title="passwordReset.title" :isTitleTranslated="true" :show-close=true :buttons="buttons" @close="closeDialog" @reset="resetPassword" name="PasswordReset">
<div> <div class="form-stack">
<label>{{ $t("passwordReset.email") }} <input type="email" v-model="email" required /></label> <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> </div>
</DialogWidget> </DialogWidget>
</template> </template>
@@ -9,6 +14,7 @@
<script> <script>
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue'; import DialogWidget from '@/components/DialogWidget.vue';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default { export default {
name: 'PasswordResetDialog', name: 'PasswordResetDialog',
@@ -18,9 +24,21 @@ export default {
data() { data() {
return { return {
email: '', 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: { methods: {
open() { open() {
this.$refs.dialog.open(); this.$refs.dialog.open();
@@ -29,15 +47,18 @@ export default {
this.$refs.dialog.close(); this.$refs.dialog.close();
}, },
async resetPassword() { async resetPassword() {
if (!this.isEmailValid) {
return;
}
try { try {
await apiClient.post('/api/users/requestPasswordReset', { await apiClient.post('/api/users/requestPasswordReset', {
email: this.email email: this.email
}); });
this.$refs.dialog.close(); this.$refs.dialog.close();
alert(this.$t("passwordReset.success")); showSuccess(this, 'tr:passwordReset.success');
} catch (error) { } catch (error) {
console.error('Error resetting password:', error); console.error('Error resetting password:', error);
alert(this.$t("passwordReset.failure")); showApiError(this, error, 'tr:passwordReset.failure');
} }
} }
} }

View File

@@ -2,18 +2,27 @@
<DialogWidget ref="dialog" title="register.title" :show-close="true" :buttons="buttons" :modal="true" <DialogWidget ref="dialog" title="register.title" :show-close="true" :buttons="buttons" :modal="true"
@close="closeDialog" @register="register" width="35em" height="33em" name="RegisterDialog" @close="closeDialog" @register="register" width="35em" height="33em" name="RegisterDialog"
:isTitleTranslated="true"> :isTitleTranslated="true">
<div class="form-content"> <div class="form-content form-stack">
<div> <div class="form-field">
<label>{{ $t("register.email") }}<input type="email" v-model="email" /></label> <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>
<div> <div class="form-field">
<label>{{ $t("register.username") }}<input type="text" v-model="username" /></label> <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>
<div> <div class="form-field">
<label>{{ $t("register.password") }}<input type="password" v-model="password" /></label> <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>
<div> <div class="form-field">
<label>{{ $t("register.repeatPassword") }}<input type="password" v-model="repeatPassword" /></label> <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> </div>
<SelectDropdownWidget labelTr="settings.personal.label.language" :v-model="language" <SelectDropdownWidget labelTr="settings.personal.label.language" :v-model="language"
tooltipTr="settings.personal.tooltip.language" :list="languages" :value="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 apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue'; import DialogWidget from '@/components/DialogWidget.vue';
import SelectDropdownWidget from '@/components/form/SelectDropdownWidget.vue'; import SelectDropdownWidget from '@/components/form/SelectDropdownWidget.vue';
import { showApiError, showError } from '@/utils/feedback.js';
export default { export default {
name: 'RegisterDialog', name: 'RegisterDialog',
@@ -41,6 +51,10 @@ export default {
repeatPassword: '', repeatPassword: '',
language: null, language: null,
languages: [], languages: [],
emailTouched: false,
usernameTouched: false,
passwordTouched: false,
repeatPasswordTouched: false,
buttons: [ buttons: [
{ text: 'register.close', action: 'close' }, { text: 'register.close', action: 'close' },
{ text: 'register.register', action: 'register', disabled: !this.canRegister } { text: 'register.register', action: 'register', disabled: !this.canRegister }
@@ -48,11 +62,35 @@ export default {
}; };
}, },
computed: { 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() { canRegister() {
return this.password && this.repeatPassword && this.password === this.repeatPassword; return this.isEmailValid && this.isUsernameValid && this.isPasswordValid && this.doPasswordsMatch && this.language;
} }
}, },
watch: { watch: {
email() {
this.emailTouched = true;
},
username() {
this.usernameTouched = true;
},
password() {
this.passwordTouched = true;
},
repeatPassword() {
this.repeatPasswordTouched = true;
},
canRegister(newValue) { canRegister(newValue) {
this.buttons[1].disabled = !newValue; this.buttons[1].disabled = !newValue;
} }
@@ -82,7 +120,7 @@ export default {
}, },
async register() { async register() {
if (!this.canRegister) { if (!this.canRegister) {
this.$root.$refs.errrorDialog.open('tr:register.passwordMismatch'); showError(this, 'tr:register.passwordMismatch');
return; return;
} }
@@ -99,14 +137,14 @@ export default {
this.$refs.dialog.close(); this.$refs.dialog.close();
this.$router.push('/activate'); this.$router.push('/activate');
} else { } else {
this.$root.$refs.errrorDialog.open("tr:register.failure"); showError(this, 'tr:register.failure');
} }
} catch (error) { } catch (error) {
if (error.response && error.response.status === 409) { 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 { } else {
console.error('Error registering user:', error); 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> </script>
<style scoped> <style scoped>
.form-content>div {
margin-bottom: 1em;
}
label {
display: block;
margin-bottom: 0.5em;
}
input[type="email"], input[type="email"],
input[type="text"], input[type="text"],
input[type="password"], input[type="password"],
select { select {
width: 100%; width: 100%;
padding: 0.5em;
box-sizing: border-box; box-sizing: border-box;
} }
</style> </style>

View File

@@ -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>

View File

@@ -3,9 +3,14 @@
@close="onDialogClose" width="75vw" height="75vh" name="MultiChatDialog" icon="multichat24.png"> @close="onDialogClose" width="75vw" height="75vh" name="MultiChatDialog" icon="multichat24.png">
<div class="dialog-widget-content"> <div class="dialog-widget-content">
<div class="multi-chat-top"> <div class="multi-chat-top">
<div class="room-left-controls">
<select v-model="selectedRoom" class="room-select"> <select v-model="selectedRoom" class="room-select">
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option> <option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
</select> </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="right-controls">
<div class="status" :class="statusType"> <div class="status" :class="statusType">
<span class="dot"></span> <span class="dot"></span>
@@ -24,14 +29,14 @@
</label> </label>
<div class="opts-divider" v-if="isAdmin"></div> <div class="opts-divider" v-if="isAdmin"></div>
<div class="opts-row" v-if="isAdmin"> <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>
</div> </div>
</div> </div>
<div v-if="!showColorPicker" class="multi-chat-body"> <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"> @mouseleave="mouseOverOutput = false">
<div v-for="msg in messages" :key="msg.id" class="chat-message"> <div v-for="msg in messages" :key="msg.id" class="chat-message">
<template v-if="msg.type === 'scream'"> <template v-if="msg.type === 'scream'">
@@ -53,6 +58,104 @@
</template> </template>
</div> </div>
</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">
<div class="user-list-header">Teilnehmer ({{ usersInRoom.length }})</div> <div class="user-list-header">Teilnehmer ({{ usersInRoom.length }})</div>
<div class="user-list-items"> <div class="user-list-items">
@@ -69,6 +172,23 @@
</div> </div>
</div> </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"> <div v-if="!showColorPicker" class="multi-chat-input">
<input v-model="input" @keyup.enter="sendMessage" class="chat-input" <input v-model="input" @keyup.enter="sendMessage" class="chat-input"
:placeholder="$t('chat.multichat.placeholder')" /> :placeholder="$t('chat.multichat.placeholder')" />
@@ -116,7 +236,7 @@
<script> <script>
import DialogWidget from '@/components/DialogWidget.vue'; 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 { mapGetters } from 'vuex';
import { getChatWsUrl, getChatWsCandidates, getChatWsProtocols } from '@/services/chatWs.js'; import { getChatWsUrl, getChatWsCandidates, getChatWsProtocols } from '@/services/chatWs.js';
@@ -130,6 +250,30 @@ export default {
try { try {
return !!(this.menu && this.menu.administration); return !!(this.menu && this.menu.administration);
} catch (_) { return false; } } 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() { mounted() {
@@ -140,6 +284,10 @@ export default {
beforeUnmount() { beforeUnmount() {
// Safety: ensure connection is shut down on page/navigation leave // Safety: ensure connection is shut down on page/navigation leave
this.opened = false; this.opened = false;
if (this.pendingRoomCreateTimer) {
clearTimeout(this.pendingRoomCreateTimer);
this.pendingRoomCreateTimer = null;
}
this.disconnectChatSocket(); this.disconnectChatSocket();
try { window.removeEventListener('online', this.onOnline); } catch (_) { } try { window.removeEventListener('online', this.onOnline); } catch (_) { }
try { document.removeEventListener('click', this.onGlobalClick); } catch (_) { } try { document.removeEventListener('click', this.onGlobalClick); } catch (_) { }
@@ -168,10 +316,33 @@ export default {
token: null, token: null,
announcedRoomEnter: false, announcedRoomEnter: false,
showColorPicker: false, showColorPicker: false,
showRoomCreatePanel: false,
selectedColor: '#000000', selectedColor: '#000000',
lastColor: '#000000', lastColor: '#000000',
hexInput: '#000000', hexInput: '#000000',
hexInvalid: false, 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 // Palette state
paletteWidth: 420, paletteWidth: 420,
paletteHeight: 220, paletteHeight: 220,
@@ -201,8 +372,8 @@ export default {
happyDelayMs: 40, happyDelayMs: 40,
// Join fallback delay if token is slow to arrive // Join fallback delay if token is slow to arrive
joinFallbackDelayMs: 120, joinFallbackDelayMs: 120,
// Limit how many parallel WS candidates to race (prevents server socket buildup) // Default: only one connection attempt in parallel (prevents duplicate daemon sockets)
raceLimit: 3 raceLimit: 1
}; };
}, },
// Hinweis: Öffnen erfolgt über methods.open(), damit Parent per Ref aufrufen kann // Hinweis: Öffnen erfolgt über methods.open(), damit Parent per Ref aufrufen kann
@@ -216,14 +387,280 @@ export default {
selectedRoom(newVal, oldVal) { selectedRoom(newVal, oldVal) {
if (newVal && this.transportConnected) { if (newVal && this.transportConnected) {
const room = this.getSelectedRoomName(); 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.messages = [];
this.usersInRoom = []; this.usersInRoom = [];
this.selectedTargetUser = null; this.selectedTargetUser = null;
this.passwordPromptVisible = false;
this.passwordPromptInvalid = false;
this.passwordPromptRoom = '';
this.passwordPromptValue = '';
} }
} }
}, },
methods: { 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) { selectTargetUser(name) {
if (this.selectedTargetUser === name) { if (this.selectedTargetUser === name) {
this.selectedTargetUser = null; // toggle off this.selectedTargetUser = null; // toggle off
@@ -260,6 +697,7 @@ export default {
onDialogClose() { onDialogClose() {
// Mark as closed first so any async close events won't schedule reconnect // Mark as closed first so any async close events won't schedule reconnect
this.opened = false; this.opened = false;
this.clearPendingRoomCreateTracking();
console.log('[Chat WS] dialog close — closing websocket'); console.log('[Chat WS] dialog close — closing websocket');
this.disconnectChatSocket(); this.disconnectChatSocket();
// Remove network event listeners // Remove network event listeners
@@ -301,14 +739,9 @@ export default {
async reloadRoomsAdmin() { async reloadRoomsAdmin() {
if (!this.isAdmin) return; if (!this.isAdmin) return;
try { try {
const current = this.selectedRoom; this.sendWithToken({ type: 'reload_rooms' });
const data = await fetchPublicRooms(); this.sendWithToken({ type: 'rooms' });
const rooms = Array.isArray(data) ? data : []; this.messages.push({ id: Date.now(), user: 'System', text: 'Raum-Reload angefordert.' });
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.' });
} catch (e) { } catch (e) {
console.error('Fehler beim Neuladen der Räume', e); console.error('Fehler beim Neuladen der Räume', e);
this.messages.push({ id: Date.now(), user: 'System', text: 'Fehler beim Neuladen der Raumliste.' }); this.messages.push({ id: Date.now(), user: 'System', text: 'Fehler beim Neuladen der Raumliste.' });
@@ -324,6 +757,9 @@ export default {
this.selectedTargetUser = null; this.selectedTargetUser = null;
this.input = ''; this.input = '';
this.showOptions = false; this.showOptions = false;
this.showRoomCreatePanel = false;
this.loadRoomCreateOptions();
this.loadOwnRooms();
this.announcedRoomEnter = false; this.announcedRoomEnter = false;
this.$refs.dialog.open(); this.$refs.dialog.open();
// Stelle die WS-Verbindung her, wenn der Dialog geöffnet wird // Stelle die WS-Verbindung her, wenn der Dialog geöffnet wird
@@ -394,12 +830,7 @@ export default {
}); });
}, },
getRaceLimit() { getRaceLimit() {
try { return 1;
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;
}, },
spawnCandidate(url, protocols) { spawnCandidate(url, protocols) {
if (!this.opened) return; if (!this.opened) return;
@@ -446,7 +877,8 @@ export default {
// Drop references to losers so GC can collect // Drop references to losers so GC can collect
this.pendingWs = []; this.pendingWs = [];
// Prepare handshake like before // 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); if (this.debug) console.log('[Chat WS >>]', init);
this.wsSend(init); this.wsSend(init);
if (this.connectAttemptTimeout) clearTimeout(this.connectAttemptTimeout); if (this.connectAttemptTimeout) clearTimeout(this.connectAttemptTimeout);
@@ -543,7 +975,8 @@ export default {
this.transportConnected = true; this.transportConnected = true;
const dt = Date.now() - (this.wsStartAt || Date.now()); const dt = Date.now() - (this.wsStartAt || Date.now());
console.log('[Chat WS] open in', dt, 'ms', '| protocol:', ws.protocol || '(none)', '| url:', url); 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); if (this.debug) console.log('[Chat WS >>]', init);
this.wsSend(init); this.wsSend(init);
if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; } if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; }
@@ -661,9 +1094,8 @@ export default {
} }
try { try {
// Send a ping message to keep connection alive // Keepalive via supported protocol command
this.wsSend({ type: 'ping' }); if (this.token) this.wsSend({ type: 'userlist', token: this.token });
console.log('[Chat WS] Heartbeat sent');
} catch (error) { } catch (error) {
console.warn('[Chat WS] Heartbeat failed:', error); console.warn('[Chat WS] Heartbeat failed:', error);
this.stopHeartbeat(); this.stopHeartbeat();
@@ -823,7 +1255,7 @@ export default {
action() { action() {
if (!this.input.trim()) return; if (!this.input.trim()) return;
if (!this.selectedTargetUser) return; // Nur mit Auswahl 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); if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload); this.sendWithToken(payload);
this.input = ''; this.input = '';
@@ -1053,6 +1485,16 @@ export default {
}, },
onWsObject(obj) { onWsObject(obj) {
if (!obj) return; 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 // Token handshake: only when explicitly marked as token-type
if (obj.type === 'token' || obj.type === 1) { if (obj.type === 'token' || obj.type === 1) {
let tok = obj.token; let tok = obj.token;
@@ -1062,6 +1504,8 @@ export default {
} }
if (tok) { if (tok) {
this.token = tok; this.token = tok;
this.sendWithToken({ type: 'rooms' });
this.sendWithToken({ type: 'userlist' });
// No extra join here; we already sent init with room // No extra join here; we already sent init with room
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; } if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
this.flushPending(); this.flushPending();
@@ -1136,11 +1580,33 @@ export default {
return; return;
} }
if (obj.type === 3 && Array.isArray(obj.message)) { if (obj.type === 3 && Array.isArray(obj.message)) {
const names = obj.message.map(r => r.name).filter(Boolean).join(', '); const mapped = obj.message
this.handleIncoming({ type: 'system', text: names ? `Rooms: ${names}` : 'Rooms updated' }); .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; return;
} }
if (obj.type === 5) { 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; const msg = obj.message;
if (typeof msg === 'string') { if (typeof msg === 'string') {
// Some servers send a JSON-encoded string here; parse if it looks like JSON // Some servers send a JSON-encoded string here; parse if it looks like JSON
@@ -1457,10 +1923,24 @@ export default {
justify-content: space-between; justify-content: space-between;
} }
.room-left-controls {
display: flex;
align-items: center;
gap: 0.5em;
}
.room-select { .room-select {
min-width: 10em; 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 { .right-controls {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1567,6 +2047,188 @@ export default {
border: 1px solid #222; 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 { .chat-message {
margin-bottom: 0.3em; margin-bottom: 0.3em;
} }

View File

@@ -60,7 +60,7 @@
{{ $t('falukant.branch.selection.selected') }}: {{ $t('falukant.branch.selection.selected') }}:
<strong>{{ selectedRegion.name }}</strong> <strong>{{ selectedRegion.name }}</strong>
</div> </div>
<label class="form-label"> <label class="form-label form-field">
{{ $t('falukant.branch.columns.type') }} {{ $t('falukant.branch.columns.type') }}
<select v-model="selectedType" class="form-control"> <select v-model="selectedType" class="form-control">
<option <option
@@ -72,8 +72,10 @@
({{ formatCost(computeBranchCost(type)) }}) ({{ formatCost(computeBranchCost(type)) }})
</option> </option>
</select> </select>
<span class="form-hint">Waehle zuerst Region und dann den Niederlassungstyp.</span>
</label> </label>
</div> </div>
<div v-else class="form-hint">Waehle auf der Karte eine freie Region aus.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -83,6 +85,7 @@
<script> <script>
import DialogWidget from '@/components/DialogWidget.vue'; import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default { export default {
name: 'CreateBranchDialog', name: 'CreateBranchDialog',
@@ -109,7 +112,7 @@
dialogButtons() { dialogButtons() {
return [ return [
{ text: this.$t('Cancel'), action: this.close }, { 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() { async onConfirm() {
if (!this.selectedRegion || !this.selectedType) return; if (!this.selectedRegion || !this.selectedType) {
showError(this, 'Bitte zuerst Region und Typ auswaehlen.');
return;
}
try { try {
await apiClient.post('/api/falukant/branches', { await apiClient.post('/api/falukant/branches', {
@@ -152,13 +158,14 @@
branchTypeId: this.selectedType, branchTypeId: this.selectedType,
}); });
this.$emit('create-branch'); this.$emit('create-branch');
showSuccess(this, 'Niederlassung erfolgreich erstellt.');
this.close(); this.close();
} catch (e) { } catch (e) {
if (e?.response?.status === 412 && e?.response?.data?.error === 'insufficientFunds') { 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 { } else {
console.error('Error creating branch', e); 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.');
} }
} }
}, },

View File

@@ -1,7 +1,8 @@
<template> <template>
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="25em" <DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="28em"
height="15em" name="ErrorDialog" :isTitleTranslated=true> height="16em" name="ErrorDialog" :isTitleTranslated=true>
<div class="error-content"> <div class="error-content">
<span class="error-content__badge">Fehler</span>
<p>{{ translatedErrorMessage }}</p> <p>{{ translatedErrorMessage }}</p>
</div> </div>
</DialogWidget> </DialogWidget>
@@ -45,8 +46,27 @@ export default {
<style scoped> <style scoped>
.error-content { .error-content {
padding: 1em; display: grid;
color: red; gap: 12px;
padding: 1.2em;
text-align: center; 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> </style>

View File

@@ -1,7 +1,8 @@
<template> <template>
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="25em" <DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="28em"
height="15em" name="MessageDialog" :isTitleTranslated=false> height="16em" name="MessageDialog" :isTitleTranslated=false>
<div class="message-content"> <div class="message-content">
<span class="message-content__badge">Hinweis</span>
<p>{{ translatedMessage }}</p> <p>{{ translatedMessage }}</p>
</div> </div>
</DialogWidget> </DialogWidget>
@@ -41,14 +42,6 @@ export default {
if (this.message.startsWith('tr:')) { if (this.message.startsWith('tr:')) {
const i18nKey = this.message.substring(3); const i18nKey = this.message.substring(3);
const translation = this.$t(i18nKey); 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.interpolateParameters(translation);
} }
return this.message; return this.message;
@@ -89,26 +82,16 @@ export default {
} }
}, },
interpolateParameters(text) { interpolateParameters(text) {
// Ersetze {key} Platzhalter mit den entsprechenden Werten
let result = text; let result = text;
console.log('interpolateParameters:', {
originalText: text,
parameters: this.parameters
});
for (const [key, value] of Object.entries(this.parameters)) { for (const [key, value] of Object.entries(this.parameters)) {
const placeholder = `{${key}}`; const placeholder = `{${key}}`;
const regex = new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'); const regex = new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g');
result = result.replace(regex, value); result = result.replace(regex, value);
console.log(`Replaced ${placeholder} with ${value}:`, result);
} }
console.log('Final result:', result);
return result; return result;
} }
}, },
beforeDestroy() { beforeUnmount() {
// Stelle sicher, dass Event Listener entfernt wird
document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keydown', this.handleKeyDown);
} }
}; };
@@ -116,8 +99,27 @@ export default {
<style scoped> <style scoped>
.message-content { .message-content {
padding: 1em; display: grid;
color: #000000; gap: 12px;
padding: 1.2em;
text-align: center; 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> </style>

View File

@@ -38,6 +38,92 @@
"connected": "Verbunden", "connected": "Verbunden",
"disconnected": "Getrennt", "disconnected": "Getrennt",
"error": "Fehler bei der Verbindung" "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": { "randomchat": {

View File

@@ -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": { "titles": {
"male": { "male": {
"noncivil": "Leibeigener", "noncivil": "Leibeigener",
@@ -471,6 +497,7 @@
"gifts": "Werbegeschenke", "gifts": "Werbegeschenke",
"sendGift": "Werbegeschenk senden", "sendGift": "Werbegeschenk senden",
"cancel": "Werbung abbrechen", "cancel": "Werbung abbrechen",
"cancelConfirm": "Willst du die Werbung wirklich abbrechen? Der Fortschritt geht dabei verloren.",
"cancelSuccess": "Die Werbung wurde abgebrochen.", "cancelSuccess": "Die Werbung wurde abgebrochen.",
"cancelError": "Die Werbung konnte nicht abgebrochen werden.", "cancelError": "Die Werbung konnte nicht abgebrochen werden.",
"cancelTooSoon": "Du kannst die Werbung erst nach 24 Stunden abbrechen.", "cancelTooSoon": "Du kannst die Werbung erst nach 24 Stunden abbrechen.",

View File

@@ -38,6 +38,92 @@
"connected": "Connected", "connected": "Connected",
"disconnected": "Disconnected", "disconnected": "Disconnected",
"error": "Connection error" "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": { "randomchat": {

View File

@@ -451,6 +451,7 @@
"spouse": { "spouse": {
"wooing": { "wooing": {
"cancel": "Cancel wooing", "cancel": "Cancel wooing",
"cancelConfirm": "Do you really want to cancel wooing? Progress will be lost.",
"cancelSuccess": "Wooing has been cancelled.", "cancelSuccess": "Wooing has been cancelled.",
"cancelError": "Wooing could not be cancelled.", "cancelError": "Wooing could not be cancelled.",
"cancelTooSoon": "You can only cancel wooing after 24 hours." "cancelTooSoon": "You can only cancel wooing after 24 hours."

View File

@@ -1,48 +1,48 @@
{ {
"admin": { "admin": {
"interests": { "interests": {
"title": "[Admin] - Interessen verwalten", "title": "[Admin] - Administrar intereses",
"newinterests": { "newinterests": {
"name": "Name des Interesses", "name": "Nombre del interés",
"status": "Freigegeben", "status": "Aprobado",
"adultonly": "Nur für Erwachsene", "adultonly": "Solo para adultos",
"translations": "Übersetzungen", "translations": "Traducciones",
"isactive": "Aktiviert", "isactive": "Activado",
"isadult": "Nur für Erwachsene", "isadult": "Solo para adultos",
"delete": "Löschen" "delete": "Eliminar"
} }
}, },
"contacts": { "contacts": {
"title": "[Admin] - Kontaktanfragen", "title": "[Admin] - Solicitudes de contacto",
"date": "Datum", "date": "Fecha",
"from": "Absender", "from": "Remitente",
"actions": "Aktionen", "actions": "Acciones",
"open": "Bearbeiten", "open": "Editar",
"finished": "Abschließen" "finished": "Finalizar"
}, },
"editcontactrequest": { "editcontactrequest": {
"title": "[Admin] - Kontaktanfrage bearbeiten" "title": "[Admin] - Editar solicitud de contacto"
}, },
"user": { "user": {
"name": "Benutzername", "name": "Nombre de usuario",
"active": "Aktiv", "active": "Activo",
"blocked": "Gesperrt", "blocked": "Bloqueado",
"actions": "Aktionen", "actions": "Acciones",
"search": "Suchen" "search": "Buscar"
}, },
"rights": { "rights": {
"add": "Recht hinzufügen", "add": "Añadir permiso",
"select": "Bitte wählen", "select": "Por favor, selecciona",
"current": "Aktuelle Rechte" "current": "Permisos actuales"
}, },
"forum": { "forum": {
"title": "[Admin] - Forum", "title": "[Admin] - Forum",
"currentForums": "Existierende Foren", "currentForums": "Foros existentes",
"edit": "Ändern", "edit": "Editar",
"delete": "Löschen", "delete": "Eliminar",
"createForum": "Anlegen", "createForum": "Crear",
"forumName": "Titel", "forumName": "Titel",
"create": "Anlegen", "create": "Crear",
"permissions": { "permissions": {
"label": "Berechtigungen", "label": "Berechtigungen",
"all": "Jeder", "all": "Jeder",
@@ -51,267 +51,267 @@
"user": "Nur bestimmte Benutzer", "user": "Nur bestimmte Benutzer",
"age": "Nur ab Alter 14" "age": "Nur ab Alter 14"
}, },
"selectPermissions": "Bitte auswählen", "selectPermissions": "Por favor, selecciona",
"confirmDeleteMessage": "Soll das Forum wirklich gelöscht werden?", "confirmDeleteMessage": "¿De verdad quieres eliminar el foro?",
"confirmDeleteTitle": "Forum löschen" "confirmDeleteTitle": "Eliminar foro"
}, },
"falukant": { "falukant": {
"edituser": { "edituser": {
"title": "Falukant Benutzer bearbeiten", "title": "Editar usuario de Falukant",
"username": "Benutzername", "username": "Nombre de usuario",
"characterName": "Charaktername", "characterName": "Nombre del personaje",
"user": "Benutzer", "user": "Usuario",
"success": "Die Änderungen wurden gespeichert.", "success": "Los cambios se han guardado.",
"error": "Die Änderungen konnten nicht gespeichert werden.", "error": "No se pudieron guardar los cambios.",
"errorLoadingBranches": "Fehler beim Laden der Niederlassungen.", "errorLoadingBranches": "Error al cargar las sucursales.",
"errorUpdatingStock": "Fehler beim Aktualisieren des Lagers.", "errorUpdatingStock": "Error al actualizar el almacén.",
"stockUpdated": "Lager erfolgreich aktualisiert.", "stockUpdated": "Almacén actualizado correctamente.",
"search": "Suchen", "search": "Buscar",
"tabs": { "tabs": {
"userdata": "Benutzerdaten", "userdata": "Datos del usuario",
"branches": "Niederlassungen" "branches": "Sucursales"
}, },
"branches": { "branches": {
"title": "Niederlassungen & Lager", "title": "Sucursales y almacén",
"noStocks": "Kein Lager vorhanden", "noStocks": "No hay almacén",
"noBranches": "Keine Niederlassungen gefunden", "noBranches": "No se han encontrado sucursales",
"addStock": "Lager hinzufügen", "addStock": "Añadir almacén",
"stockType": "Lagertyp", "stockType": "Tipo de almacén",
"selectStockType": "Lagertyp auswählen", "selectStockType": "Seleccionar tipo de almacén",
"quantity": "Menge", "quantity": "Cantidad",
"allStocksAdded": "Alle verfügbaren Lagertypen sind bereits vorhanden" "allStocksAdded": "Todos los tipos de almacén disponibles ya existen"
}, },
"errorLoadingStockTypes": "Fehler beim Laden der Lagertypen.", "errorLoadingStockTypes": "Error al cargar los tipos de almacén.",
"errorAddingStock": "Fehler beim Hinzufügen des Lagers.", "errorAddingStock": "Error al añadir el almacén.",
"stockAdded": "Lager erfolgreich hinzugefügt.", "stockAdded": "Almacén añadido correctamente.",
"invalidStockData": "Bitte gültige Lagertyp- und Mengenangaben eingeben." "invalidStockData": "Por favor, introduce un tipo de almacén y una cantidad válidos."
}, },
"map": { "map": {
"title": "Falukant Karten-Editor (Regionen)", "title": "Editor de mapas de Falukant (regiones)",
"description": "Zeichne Rechtecke auf der Falukant-Karte und weise sie Städten zu.", "description": "Dibuja rectángulos en el mapa de Falukant y asígnalos a ciudades.",
"tabs": { "tabs": {
"regions": "Positionen", "regions": "Posiciones",
"distances": "Entfernungen" "distances": "Distancias"
}, },
"regionList": "Städte", "regionList": "Ciudades",
"noCoords": "Keine Koordinaten gesetzt", "noCoords": "No se han establecido coordenadas",
"currentRect": "Aktuelles Rechteck", "currentRect": "Rectángulo actual",
"hintDraw": "Wähle eine Stadt und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.", "hintDraw": "Elige una ciudad y dibuja un rectángulo con el ratón sobre el mapa para definir la posición.",
"saveAll": "Alle geänderten Städte speichern", "saveAll": "Guardar todas las ciudades modificadas",
"connectionsTitle": "Verbindungen (region_distance)", "connectionsTitle": "Conexiones (region_distance)",
"source": "Von", "source": "Desde",
"target": "Nach", "target": "Hacia",
"selectSource": "Quellstadt wählen", "selectSource": "Elegir ciudad origen",
"selectTarget": "Zielstadt wählen", "selectTarget": "Elegir ciudad destino",
"mode": "Transportart", "mode": "Transportart",
"modeLand": "Land", "modeLand": "Land",
"modeWater": "Wasser", "modeWater": "Wasser",
"modeAir": "Luft", "modeAir": "Luft",
"distance": "Entfernung", "distance": "Distancia",
"saveConnection": "Verbindung speichern", "saveConnection": "Guardar conexión",
"pickOnMap": "Auf Karte wählen", "pickOnMap": "Seleccionar en el mapa",
"errorSaveConnection": "Die Verbindung konnte nicht gespeichert werden.", "errorSaveConnection": "No se pudo guardar la conexión.",
"errorDeleteConnection": "Die Verbindung konnte nicht gelöscht werden.", "errorDeleteConnection": "No se pudo eliminar la conexión.",
"confirmDeleteConnection": "Verbindung wirklich löschen?" "confirmDeleteConnection": "¿Eliminar la conexión?"
}, },
"createNPC": { "createNPC": {
"title": "NPCs erstellen", "title": "Crear NPC",
"region": "Stadt", "region": "Ciudad",
"allRegions": "Alle Städte", "allRegions": "Todas las ciudades",
"ageRange": "Altersbereich", "ageRange": "Rango de edad",
"to": "bis", "to": "hasta",
"years": "Jahre", "years": "años",
"titleRange": "Titel-Bereich", "titleRange": "Rango de títulos",
"count": "Anzahl pro Stadt-Titel-Kombination", "count": "Cantidad por combinación ciudad-título",
"countHelp": "Diese Anzahl wird für jede Kombination aus gewählter Stadt und Titel erstellt.", "countHelp": "Esta cantidad se crea para cada combinación de ciudad y título seleccionados.",
"create": "NPCs erstellen", "create": "Crear NPC",
"creating": "Erstelle...", "creating": "Creando...",
"result": "Ergebnis", "result": "Resultado",
"createdCount": "{count} NPCs wurden erstellt.", "createdCount": "Se han creado {count} NPC.",
"combinationInfo": "{perCombination} NPCs pro Kombination × {combinations} Kombinationen = {count} NPCs insgesamt", "combinationInfo": "{perCombination} NPC por combinación × {combinations} combinaciones = {count} NPC en total",
"age": "Alter", "age": "Edad",
"errorLoadingRegions": "Fehler beim Laden der Städte.", "errorLoadingRegions": "Error al cargar las ciudades.",
"errorLoadingTitles": "Fehler beim Laden der Titel.", "errorLoadingTitles": "Error al cargar los títulos.",
"errorCreating": "Fehler beim Erstellen der NPCs.", "errorCreating": "Error al crear los NPC.",
"invalidAgeRange": "Ungültiger Altersbereich.", "invalidAgeRange": "Rango de edad no válido.",
"invalidTitleRange": "Ungültiger Titel-Bereich.", "invalidTitleRange": "Rango de títulos no válido.",
"invalidCount": "Ungültige Anzahl (1-500).", "invalidCount": "Cantidad no válida (1-500).",
"progress": "Fortschritt", "progress": "Progreso",
"progressDetails": "{current} von {total} NPCs erstellt", "progressDetails": "{current} de {total} NPC creados",
"timeRemainingSeconds": "Verbleibende Zeit: {seconds} Sekunden", "timeRemainingSeconds": "Tiempo restante: {seconds} segundos",
"timeRemainingMinutes": "Verbleibende Zeit: {minutes} Minuten {seconds} Sekunden", "timeRemainingMinutes": "Tiempo restante: {minutes} minutos {seconds} segundos",
"almostDone": "Fast fertig...", "almostDone": "Casi listo...",
"jobNotFound": "Job nicht gefunden oder abgelaufen." "jobNotFound": "Trabajo no encontrado o caducado."
} }
}, },
"chatrooms": { "chatrooms": {
"title": "[Admin] - Chaträume verwalten", "title": "[Admin] - Administrar salas de chat",
"roomName": "Raumname", "roomName": "Nombre de la sala",
"create": "Chatraum anlegen", "create": "Crear sala de chat",
"edit": "Chatraum bearbeiten", "edit": "Editar sala de chat",
"type": "Typ", "type": "Typ",
"isPublic": "Öffentlich sichtbar", "isPublic": "Visible públicamente",
"actions": "Aktionen", "actions": "Acciones",
"genderRestriction": { "genderRestriction": {
"show": "Geschlechtsbeschränkung aktivieren", "show": "Activar restricción de género",
"label": "Geschlechtsbeschränkung" "label": "Restricción de género"
}, },
"minAge": { "minAge": {
"show": "Mindestalter angeben", "show": "Indicar edad mínima",
"label": "Mindestalter" "label": "Edad mínima"
}, },
"maxAge": { "maxAge": {
"show": "Höchstalter angeben", "show": "Indicar edad máxima",
"label": "Höchstalter" "label": "Edad máxima"
}, },
"password": { "password": {
"show": "Passwortschutz aktivieren", "show": "Activar protección con contraseña",
"label": "Passwort" "label": "Contraseña"
}, },
"friendsOfOwnerOnly": "Nur Freunde des Besitzers", "friendsOfOwnerOnly": "Nur Freunde des Besitzers",
"requiredUserRight": { "requiredUserRight": {
"show": "Benötigtes Benutzerrecht angeben", "show": "Indicar permiso de usuario requerido",
"label": "Benötigtes Benutzerrecht" "label": "Permiso de usuario requerido"
}, },
"roomtype": { "roomtype": {
"chat": "Reden", "chat": "Chat",
"dice": "Würfeln", "dice": "Dados",
"poker": "Poker", "poker": "Poker",
"hangman": "Hangman" "hangman": "Hangman"
}, },
"rights": { "rights": {
"talk": "Reden", "talk": "Hablar",
"scream": "Schreien", "scream": "Gritar",
"whisper": "Flüstern", "whisper": "Susurrar",
"start game": "Spiel starten", "start game": "Iniciar juego",
"open room": "Raum öffnen", "open room": "Abrir sala",
"systemmessage": "Systemnachricht" "systemmessage": "Mensaje del sistema"
}, },
"confirmDelete": "Soll dieser Chatraum wirklich gelöscht werden?" "confirmDelete": "¿De verdad quieres eliminar esta sala de chat?"
}, },
"match3": { "match3": {
"title": "Match3 Level verwalten", "title": "Administrar niveles de Match3",
"newLevel": "Neues Level erstellen", "newLevel": "Crear nuevo nivel",
"editLevel": "Level bearbeiten", "editLevel": "Editar nivel",
"deleteLevel": "Level löschen", "deleteLevel": "Eliminar nivel",
"confirmDelete": "Möchtest du dieses Level wirklich löschen?", "confirmDelete": "¿De verdad quieres eliminar este nivel?",
"levelName": "Name", "levelName": "Name",
"levelDescription": "Beschreibung", "levelDescription": "Descripción",
"boardWidth": "Breite", "boardWidth": "Breite",
"boardHeight": "Höhe", "boardHeight": "Altura",
"moveLimit": "Zug-Limit", "moveLimit": "Zug-Limit",
"levelOrder": "Reihenfolge", "levelOrder": "Reihenfolge",
"boardLayout": "Board-Layout", "boardLayout": "Board-Layout",
"tileTypes": "Verfügbare Tile-Typen", "tileTypes": "Tipos de fichas disponibles",
"actions": "Aktionen", "actions": "Acciones",
"edit": "Bearbeiten", "edit": "Editar",
"delete": "Löschen", "delete": "Eliminar",
"save": "Speichern", "save": "Guardar",
"cancel": "Abbrechen", "cancel": "Cancelar",
"update": "Aktualisieren", "update": "Actualizar",
"create": "Erstellen", "create": "Crear",
"boardControls": { "boardControls": {
"fillAll": "Alle aktivieren", "fillAll": "Activar todo",
"clearAll": "Alle deaktivieren", "clearAll": "Desactivar todo",
"invert": "Invertieren" "invert": "Invertir"
}, },
"loading": "Lade Level...", "loading": "Cargando niveles...",
"retry": "Erneut versuchen", "retry": "Reintentar",
"availableLevels": "Verfügbare Level: {count}", "availableLevels": "Niveles disponibles: {count}",
"levelFormat": "Level {number}: {name}", "levelFormat": "Level {number}: {name}",
"levelObjectives": "Level-Objekte", "levelObjectives": "Level-Objekte",
"objectivesTitle": "Siegvoraussetzungen", "objectivesTitle": "Siegvoraussetzungen",
"addObjective": "Objektiv hinzufügen", "addObjective": "Añadir objetivo",
"removeObjective": "Entfernen", "removeObjective": "Eliminar",
"objectiveType": "Typ", "objectiveType": "Typ",
"objectiveTypeScore": "Punkte sammeln", "objectiveTypeScore": "Punkte sammeln",
"objectiveTypeMatches": "Matches machen", "objectiveTypeMatches": "Matches machen",
"objectiveTypeMoves": "Züge verwenden", "objectiveTypeMoves": "Usar movimientos",
"objectiveTypeTime": "Zeit einhalten", "objectiveTypeTime": "Zeit einhalten",
"objectiveTypeSpecial": "Spezialziel", "objectiveTypeSpecial": "Spezialziel",
"objectiveOperator": "Operator", "objectiveOperator": "Operator",
"operatorGreaterEqual": "Größer oder gleich (≥)", "operatorGreaterEqual": "Mayor o igual (≥)",
"operatorLessEqual": "Kleiner oder gleich (≤)", "operatorLessEqual": "Menor o igual (≤)",
"operatorEqual": "Gleich (=)", "operatorEqual": "Gleich (=)",
"operatorGreater": "Größer als (>)", "operatorGreater": "Mayor que (>)",
"operatorLess": "Kleiner als (<)", "operatorLess": "Menor que (<)",
"objectiveTarget": "Zielwert", "objectiveTarget": "Zielwert",
"objectiveTargetPlaceholder": "z.B. 100", "objectiveTargetPlaceholder": "z.B. 100",
"objectiveOrder": "Reihenfolge", "objectiveOrder": "Reihenfolge",
"objectiveOrderPlaceholder": "1, 2, 3...", "objectiveOrderPlaceholder": "1, 2, 3...",
"objectiveDescription": "Beschreibung", "objectiveDescription": "Descripción",
"objectiveDescriptionPlaceholder": "z.B. Sammle 100 Punkte", "objectiveDescriptionPlaceholder": "z.B. Sammle 100 Punkte",
"objectiveRequired": "Erforderlich für Level-Abschluss", "objectiveRequired": "Requerido para completar el nivel",
"noObjectives": "Keine Siegvoraussetzungen definiert. Klicke auf 'Objektiv hinzufügen' um welche zu erstellen." "noObjectives": "No hay condiciones de victoria definidas. Haz clic en 'Añadir objetivo' para crear una."
}, },
"userStatistics": { "userStatistics": {
"title": "[Admin] - Benutzerstatistiken", "title": "[Admin] - Estadísticas de usuarios",
"totalUsers": "Gesamtanzahl Benutzer", "totalUsers": "Total de usuarios",
"genderDistribution": "Geschlechterverteilung", "genderDistribution": "Distribución por género",
"ageDistribution": "Altersverteilung" "ageDistribution": "Distribución por edad"
}, },
"taxiTools": { "taxiTools": {
"title": "Taxi-Tools", "title": "Taxi-Tools",
"description": "Verwalte Taxi-Maps, Level und Konfigurationen", "description": "Administra mapas, niveles y configuraciones de Taxi",
"mapEditor": { "mapEditor": {
"title": "Map bearbeiten", "title": "Editar mapa",
"availableMaps": "Verfügbare Maps: {count}", "availableMaps": "Mapas disponibles: {count}",
"newMap": "Neue Map erstellen", "newMap": "Crear nuevo mapa",
"mapFormat": "{name} (Position: {x},{y})", "mapFormat": "{name} (Position: {x},{y})",
"mapName": "Map-Name", "mapName": "Map-Name",
"mapDescription": "Beschreibung", "mapDescription": "Descripción",
"mapWidth": "Breite", "mapWidth": "Breite",
"mapHeight": "Höhe", "mapHeight": "Altura",
"tileSize": "Tile-Größe", "tileSize": "Tamaño de las fichas",
"positionX": "X-Position", "positionX": "X-Position",
"positionY": "Y-Position", "positionY": "Y-Position",
"mapType": "Map-Typ", "mapType": "Map-Typ",
"mapLayout": "Map-Layout", "mapLayout": "Map-Layout",
"tilePalette": "Tile-Palette", "tilePalette": "Tile-Palette",
"streetNames": "Straßennamen", "streetNames": "Nombres de calles",
"extraElements": "Zusätzliche Elemente", "extraElements": "Elementos adicionales",
"streetNameHorizontal": "Straßenname (horizontal)", "streetNameHorizontal": "Nombre de calle (horizontal)",
"streetNameVertical": "Straßenname (vertikal)", "streetNameVertical": "Nombre de calle (vertical)",
"continueHorizontal": "In anderer Richtung fortführen (→)", "continueHorizontal": "Continuar en otra dirección (→)",
"continueVertical": "In anderer Richtung fortführen (↓)", "continueVertical": "Continuar en otra dirección (↓)",
"continueOther": "In anderer Richtung fortführen", "continueOther": "Continuar en otra dirección",
"position": "Position", "position": "Position",
"fillAllRoads": "Alle Straßen", "fillAllRoads": "Todas las calles",
"clearAll": "Alle löschen", "clearAll": "Borrar todo",
"generateRandom": "Zufällig generieren", "generateRandom": "Generar aleatoriamente",
"delete": "Löschen", "delete": "Eliminar",
"update": "Aktualisieren", "update": "Actualizar",
"cancel": "Abbrechen", "cancel": "Cancelar",
"create": "Erstellen", "create": "Crear",
"createSuccess": "Map wurde erfolgreich erstellt!", "createSuccess": "¡El mapa se ha creado correctamente!",
"updateSuccess": "Map wurde erfolgreich aktualisiert!", "updateSuccess": "¡El mapa se ha actualizado correctamente!",
"deleteSuccess": "Map wurde erfolgreich gelöscht!" "deleteSuccess": "¡El mapa se ha eliminado correctamente!"
} }
}, },
"servicesStatus": { "servicesStatus": {
"title": "Service-Status", "title": "Service-Status",
"description": "Überwache den Status von Backend, Chat und Daemon", "description": "Supervisa el estado del backend, el chat y el daemon",
"status": { "status": {
"connected": "Verbunden", "connected": "Conectado",
"connecting": "Verbinde...", "connecting": "Conectando...",
"disconnected": "Nicht verbunden", "disconnected": "Desconectado",
"error": "Fehler", "error": "Error",
"unknown": "Unbekannt" "unknown": "Desconocido"
}, },
"backend": { "backend": {
"title": "Backend", "title": "Backend",
"connected": "Backend-Service ist erreichbar und verbunden" "connected": "El servicio de backend está accesible y conectado"
}, },
"chat": { "chat": {
"title": "Chat", "title": "Chat",
"connected": "Chat-Service ist erreichbar und verbunden" "connected": "El servicio de chat está accesible y conectado"
}, },
"daemon": { "daemon": {
"title": "Daemon", "title": "Daemon",
"connected": "Daemon-Service ist erreichbar und verbunden", "connected": "El servicio daemon está accesible y conectado",
"connections": { "connections": {
"title": "Aktive Verbindungen", "title": "Aktive Verbindungen",
"none": "Keine aktiven Verbindungen", "none": "No hay conexiones activas",
"userId": "Benutzer-ID", "userId": "Benutzer-ID",
"username": "Benutzername", "username": "Benutzername",
"connections": "Verbindungen", "connections": "Verbindungen",
@@ -321,21 +321,21 @@
"pongReceived": "Pong empfangen", "pongReceived": "Pong empfangen",
"yes": "Ja", "yes": "Ja",
"no": "Nein", "no": "Nein",
"notConnected": "Daemon nicht verbunden", "notConnected": "Daemon no conectado",
"sendError": "Fehler beim Senden der Anfrage", "sendError": "Error al enviar la solicitud",
"error": "Fehler beim Abrufen der Verbindungen" "error": "Error al obtener las conexiones"
}, },
"websocketLog": { "websocketLog": {
"title": "WebSocket-Log", "title": "WebSocket-Log",
"showLog": "WebSocket-Log anzeigen", "showLog": "WebSocket-Log anzeigen",
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"loading": "Lädt...", "loading": "Cargando...",
"close": "Schließen", "close": "Cerrar",
"entryCount": "{count} Einträge", "entryCount": "{count} entradas",
"noEntries": "Keine Log-Einträge vorhanden", "noEntries": "No hay entradas de registro",
"notConnected": "Daemon nicht verbunden", "notConnected": "Daemon no conectado",
"sendError": "Fehler beim Senden der Anfrage", "sendError": "Error al enviar la solicitud",
"parseError": "Fehler beim Verarbeiten der Antwort", "parseError": "Error al procesar la respuesta",
"timestamp": "Zeitstempel", "timestamp": "Zeitstempel",
"direction": "Richtung", "direction": "Richtung",
"peer": "Peer", "peer": "Peer",

View File

@@ -37,6 +37,92 @@
"connected": "Conectado", "connected": "Conectado",
"disconnected": "Desconectado", "disconnected": "Desconectado",
"error": "Error de conexión" "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": { "randomchat": {

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import i18n from './i18n';
import { createVuetify } from 'vuetify'; import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components'; import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives'; import * as directives from 'vuetify/directives';
import feedbackPlugin from './utils/feedback';
function getBrowserLanguage() { function getBrowserLanguage() {
// Prüfe zuerst die bevorzugte Sprache // Prüfe zuerst die bevorzugte Sprache
@@ -56,5 +57,6 @@ app.use(store);
app.use(router); app.use(router);
app.use(i18n); app.use(i18n);
app.use(vuetify); app.use(vuetify);
app.use(feedbackPlugin);
app.mount('#app'); app.mount('#app');

View File

@@ -1,16 +1,16 @@
import AdminInterestsView from '../views/admin/InterestsView.vue'; const AdminInterestsView = () => import('../views/admin/InterestsView.vue');
import AdminContactsView from '../views/admin/ContactsView.vue'; const AdminContactsView = () => import('../views/admin/ContactsView.vue');
import RoomsView from '../views/admin/RoomsView.vue'; const RoomsView = () => import('../views/admin/RoomsView.vue');
import UserRightsView from '../views/admin/UserRightsView.vue'; const UserRightsView = () => import('../views/admin/UserRightsView.vue');
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue'; const ForumAdminView = () => import('../dialogues/admin/ForumAdminView.vue');
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue'; const AdminFalukantEditUserView = () => import('../views/admin/falukant/EditUserView.vue');
import AdminFalukantMapRegionsView from '../views/admin/falukant/MapRegionsView.vue'; const AdminFalukantMapRegionsView = () => import('../views/admin/falukant/MapRegionsView.vue');
import AdminFalukantCreateNPCView from '../views/admin/falukant/CreateNPCView.vue'; const AdminFalukantCreateNPCView = () => import('../views/admin/falukant/CreateNPCView.vue');
import AdminMinigamesView from '../views/admin/MinigamesView.vue'; const AdminMinigamesView = () => import('../views/admin/MinigamesView.vue');
import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue'; const AdminTaxiToolsView = () => import('../views/admin/TaxiToolsView.vue');
import AdminUsersView from '../views/admin/UsersView.vue'; const AdminUsersView = () => import('../views/admin/UsersView.vue');
import UserStatisticsView from '../views/admin/UserStatisticsView.vue'; const UserStatisticsView = () => import('../views/admin/UserStatisticsView.vue');
import ServicesStatusView from '../views/admin/ServicesStatusView.vue'; const ServicesStatusView = () => import('../views/admin/ServicesStatusView.vue');
const adminRoutes = [ const adminRoutes = [
{ {

View File

@@ -1,10 +1,13 @@
import ActivateView from '../views/auth/ActivateView.vue'; const ActivateView = () => import('../views/auth/ActivateView.vue');
const authRoutes = [ const authRoutes = [
{ {
path: '/activate', path: '/activate',
name: 'Activate page', name: 'Activate page',
component: ActivateView component: ActivateView,
meta: {
robots: 'noindex, nofollow'
}
}, },
]; ];

View File

@@ -1,13 +1,58 @@
import BlogListView from '@/views/blog/BlogListView.vue'; const BlogListView = () => import('@/views/blog/BlogListView.vue');
import BlogView from '@/views/blog/BlogView.vue'; const BlogView = () => import('@/views/blog/BlogView.vue');
import BlogEditorView from '@/views/blog/BlogEditorView.vue'; const BlogEditorView = () => import('@/views/blog/BlogEditorView.vue');
import { buildAbsoluteUrl } from '@/utils/seo.js';
export default [ export default [
{ path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } }, { path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } },
{ path: '/blogs/:id/edit', name: 'BlogEdit', component: BlogEditorView, props: true, 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 // 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 // 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',
},
],
},
},
},
]; ];

View File

@@ -1,18 +1,18 @@
import BranchView from '../views/falukant/BranchView.vue'; const BranchView = () => import('../views/falukant/BranchView.vue');
import Createview from '../views/falukant/CreateView.vue'; const Createview = () => import('../views/falukant/CreateView.vue');
import FalukantOverviewView from '../views/falukant/OverviewView.vue'; const FalukantOverviewView = () => import('../views/falukant/OverviewView.vue');
import MoneyHistoryView from '../views/falukant/MoneyHistoryView.vue'; const MoneyHistoryView = () => import('../views/falukant/MoneyHistoryView.vue');
import FamilyView from '../views/falukant/FamilyView.vue'; const FamilyView = () => import('../views/falukant/FamilyView.vue');
import HouseView from '../views/falukant/HouseView.vue'; const HouseView = () => import('../views/falukant/HouseView.vue');
import NobilityView from '../views/falukant/NobilityView.vue'; const NobilityView = () => import('../views/falukant/NobilityView.vue');
import ReputationView from '../views/falukant/ReputationView.vue'; const ReputationView = () => import('../views/falukant/ReputationView.vue');
import ChurchView from '../views/falukant/ChurchView.vue'; const ChurchView = () => import('../views/falukant/ChurchView.vue');
import EducationView from '../views/falukant/EducationView.vue'; const EducationView = () => import('../views/falukant/EducationView.vue');
import BankView from '../views/falukant/BankView.vue'; const BankView = () => import('../views/falukant/BankView.vue');
import DirectorView from '../views/falukant/DirectorView.vue'; const DirectorView = () => import('../views/falukant/DirectorView.vue');
import HealthView from '../views/falukant/HealthView.vue'; const HealthView = () => import('../views/falukant/HealthView.vue');
import PoliticsView from '../views/falukant/PoliticsView.vue'; const PoliticsView = () => import('../views/falukant/PoliticsView.vue');
import UndergroundView from '../views/falukant/UndergroundView.vue'; const UndergroundView = () => import('../views/falukant/UndergroundView.vue');
const falukantRoutes = [ const falukantRoutes = [
{ {

View File

@@ -1,6 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import store from '../store'; import store from '../store';
import HomeView from '../views/HomeView.vue';
import authRoutes from './authRoutes'; import authRoutes from './authRoutes';
import socialRoutes from './socialRoutes'; import socialRoutes from './socialRoutes';
import settingsRoutes from './settingsRoutes'; import settingsRoutes from './settingsRoutes';
@@ -9,13 +8,41 @@ import falukantRoutes from './falukantRoutes';
import blogRoutes from './blogRoutes'; import blogRoutes from './blogRoutes';
import minigamesRoutes from './minigamesRoutes'; import minigamesRoutes from './minigamesRoutes';
import personalRoutes from './personalRoutes'; import personalRoutes from './personalRoutes';
import marketingRoutes from './marketingRoutes';
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
const HomeView = () => import('../views/HomeView.vue');
const routes = [ const routes = [
{ {
path: '/', path: '/',
name: 'Home', 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, ...authRoutes,
...socialRoutes, ...socialRoutes,
...settingsRoutes, ...settingsRoutes,
@@ -45,4 +72,8 @@ router.beforeEach((to, from, next) => {
} }
}); });
router.afterEach((to) => {
applyRouteSeo(to);
});
export default router; export default router;

View 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;

View File

@@ -1,9 +1,9 @@
import PeronalSettingsView from '../views/settings/PersonalView.vue'; const PeronalSettingsView = () => import('../views/settings/PersonalView.vue');
import ViewSettingsView from '../views/settings/ViewView.vue'; const ViewSettingsView = () => import('../views/settings/ViewView.vue');
import FlirtSettingsView from '../views/settings/FlirtView.vue'; const FlirtSettingsView = () => import('../views/settings/FlirtView.vue');
import SexualitySettingsView from '../views/settings/SexualityView.vue'; const SexualitySettingsView = () => import('../views/settings/SexualityView.vue');
import AccountSettingsView from '../views/settings/AccountView.vue'; const AccountSettingsView = () => import('../views/settings/AccountView.vue');
import InterestsView from '../views/settings/InterestsView.vue'; const InterestsView = () => import('../views/settings/InterestsView.vue');
const settingsRoutes = [ const settingsRoutes = [
{ {

View File

@@ -1,18 +1,18 @@
import FriendsView from '../views/social/FriendsView.vue'; const FriendsView = () => import('../views/social/FriendsView.vue');
import SearchView from '../views/social/SearchView.vue'; const SearchView = () => import('../views/social/SearchView.vue');
import GalleryView from '../views/social/GalleryView.vue'; const GalleryView = () => import('../views/social/GalleryView.vue');
import GuestbookView from '../views/social/GuestbookView.vue'; const GuestbookView = () => import('../views/social/GuestbookView.vue');
import DiaryView from '../views/social/DiaryView.vue'; const DiaryView = () => import('../views/social/DiaryView.vue');
import ForumView from '../views/social/ForumView.vue'; const ForumView = () => import('../views/social/ForumView.vue');
import ForumTopicView from '../views/social/ForumTopicView.vue'; const ForumTopicView = () => import('../views/social/ForumTopicView.vue');
import VocabTrainerView from '../views/social/VocabTrainerView.vue'; const VocabTrainerView = () => import('../views/social/VocabTrainerView.vue');
import VocabNewLanguageView from '../views/social/VocabNewLanguageView.vue'; const VocabNewLanguageView = () => import('../views/social/VocabNewLanguageView.vue');
import VocabLanguageView from '../views/social/VocabLanguageView.vue'; const VocabLanguageView = () => import('../views/social/VocabLanguageView.vue');
import VocabSubscribeView from '../views/social/VocabSubscribeView.vue'; const VocabSubscribeView = () => import('../views/social/VocabSubscribeView.vue');
import VocabChapterView from '../views/social/VocabChapterView.vue'; const VocabChapterView = () => import('../views/social/VocabChapterView.vue');
import VocabCourseListView from '../views/social/VocabCourseListView.vue'; const VocabCourseListView = () => import('../views/social/VocabCourseListView.vue');
import VocabCourseView from '../views/social/VocabCourseView.vue'; const VocabCourseView = () => import('../views/social/VocabCourseView.vue');
import VocabLessonView from '../views/social/VocabLessonView.vue'; const VocabLessonView = () => import('../views/social/VocabLessonView.vue');
const socialRoutes = [ const socialRoutes = [
{ {

View File

@@ -1,3 +1,5 @@
import { getChatWsUrlFromEnv } from '@/utils/appConfig.js';
// Small helper to resolve the Chat WebSocket URL from env or sensible defaults // Small helper to resolve the Chat WebSocket URL from env or sensible defaults
export function getChatWsUrl() { export function getChatWsUrl() {
// Prefer explicit env var // Prefer explicit env var
@@ -5,24 +7,7 @@ export function getChatWsUrl() {
if (override && typeof override === 'string' && override.trim()) { if (override && typeof override === 'string' && override.trim()) {
return override.trim(); return override.trim();
} }
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL; return getChatWsUrlFromEnv();
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;
} }
// Provide a list of candidate WS URLs to try, in order of likelihood. // 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()) { if (override && typeof override === 'string' && override.trim()) {
return [override.trim()]; return [override.trim()];
} }
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL; const resolved = getChatWsUrlFromEnv();
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) { return [resolved, `${resolved}/`];
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;
} }
// Return optional subprotocols for the WebSocket handshake. // Return optional subprotocols for the WebSocket handshake.

View File

@@ -4,6 +4,7 @@ import loadMenu from '../utils/menuLoader.js';
import router from '../router'; import router from '../router';
import apiClient from '../utils/axios.js'; import apiClient from '../utils/axios.js';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
import { getDaemonSocketUrl, getSocketIoUrl } from '../utils/appConfig.js';
const store = createStore({ const store = createStore({
state: { state: {
@@ -180,38 +181,7 @@ const store = createStore({
commit('setConnectionStatus', 'connecting'); commit('setConnectionStatus', 'connecting');
// Socket.io URL für lokale Entwicklung und Produktion let socketIoUrl = getSocketIoUrl();
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;
}
}
// Socket.io-Konfiguration: In Produktion mit HTTPS verwenden wir wss:// // Socket.io-Konfiguration: In Produktion mit HTTPS verwenden wir wss://
const socketOptions = { const socketOptions = {
@@ -287,29 +257,7 @@ const store = createStore({
// Daemon URL für lokale Entwicklung und Produktion // Daemon URL für lokale Entwicklung und Produktion
// Vite bindet Umgebungsvariablen zur Build-Zeit ein, daher Fallback-Logik basierend auf Hostname // Vite bindet Umgebungsvariablen zur Build-Zeit ein, daher Fallback-Logik basierend auf Hostname
const hostname = window.location.hostname; let daemonUrl = getDaemonSocketUrl();
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);
}
console.log('[Daemon] Finale Daemon-URL:', daemonUrl); console.log('[Daemon] Finale Daemon-URL:', daemonUrl);

View 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());
}

View File

@@ -1,20 +1,10 @@
import axios from 'axios'; import axios from 'axios';
import store from '../store'; import store from '../store';
import { getApiBaseUrl } from './appConfig.js';
// API-Basis-URL - Apache-Proxy für Produktion, direkte Verbindung für lokale Entwicklung // API-Basis-URL - Apache-Proxy für Produktion, direkte Verbindung für lokale Entwicklung
const getApiBaseURL = () => { const getApiBaseURL = () => {
// Wenn explizite Umgebungsvariable gesetzt ist, diese verwenden return getApiBaseUrl();
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 '';
}; };

View File

@@ -1,12 +1,10 @@
// Centralized config for YourChat protocol mapping and WS endpoint // Centralized config for YourChat protocol mapping and WS endpoint
// Override via .env (VITE_* variables) // Override via .env (VITE_* variables)
import { getChatWsUrlFromEnv } from './appConfig.js';
const env = import.meta.env || {}; const env = import.meta.env || {};
export const CHAT_WS_URL = env.VITE_CHAT_WS_URL export const CHAT_WS_URL = getChatWsUrlFromEnv();
|| (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/');
// Event/type keys // Event/type keys
export const CHAT_EVENT_KEY = env.VITE_CHAT_EVENT_KEY || 'type'; export const CHAT_EVENT_KEY = env.VITE_CHAT_EVENT_KEY || 'type';

View 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
View 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();
}

View File

@@ -3,9 +3,11 @@
</template> </template>
<script> <script>
import { defineAsyncComponent } from 'vue';
import { mapGetters } from 'vuex'; 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 { export default {
name: 'HomeView', name: 'HomeView',

View File

@@ -15,7 +15,7 @@
<tbody> <tbody>
<tr v-for="room in rooms" :key="room.id"> <tr v-for="room in rooms" :key="room.id">
<td>{{ room.title }}</td> <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>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
<td> <td>
<button @click="editRoom(room)">{{ $t('common.edit') }}</button> <button @click="editRoom(room)">{{ $t('common.edit') }}</button>
@@ -48,6 +48,14 @@ export default {
this.fetchRooms(); this.fetchRooms();
}, },
methods: { 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() { openCreateDialog() {
this.selectedRoom = null; this.selectedRoom = null;
this.$refs.roomDialog.open(); this.$refs.roomDialog.open();

View File

@@ -10,6 +10,36 @@
<!-- Match3 Levels Tab --> <!-- Match3 Levels Tab -->
<div v-if="activeTab === 'match3-levels'" class="match3-admin"> <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"> <div class="section-header">
<h2>{{ $t('admin.match3.title') }}</h2> <h2>{{ $t('admin.match3.title') }}</h2>
</div> </div>
@@ -31,13 +61,38 @@
{{ $t('admin.match3.levelFormat', { number: level.order, name: level.name }) }} {{ $t('admin.match3.levelFormat', { number: level.order, name: level.name }) }}
</option> </option>
</select> </select>
<button type="button" class="btn btn-secondary level-select-action" @click="createLevel">
{{ $t('admin.match3.newLevel') }}
</button>
</div> </div>
<p class="level-selection__hint">
{{ isCreatingLevel ? 'Du erstellst gerade ein neues Level.' : 'Du bearbeitest ein bestehendes Level mit allen verbundenen Objectives.' }}
</p>
</div> </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 --> <!-- Level Details -->
<div v-if="selectedLevelId !== 'new' && selectedLevel" class="level-details"> <div v-if="selectedLevelId !== 'new' && selectedLevel" class="level-details">
<div class="details-header"> <div class="details-header">
<h3>{{ selectedLevel.name }}</h3> <h3>{{ selectedLevel.name }}</h3>
<p>Bestehendes Level anpassen, ohne den Kontext des aktuellen Spielflusses zu verlieren.</p>
</div> </div>
<div class="details-content"> <div class="details-content">
<div class="form-group"> <div class="form-group">
@@ -185,7 +240,7 @@
<button type="button" class="btn btn-danger" @click="deleteSelectedLevel"> <button type="button" class="btn btn-danger" @click="deleteSelectedLevel">
{{ $t('admin.match3.delete') }} {{ $t('admin.match3.delete') }}
</button> </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') }} {{ $t('admin.match3.update') }}
</button> </button>
</div> </div>
@@ -539,7 +594,7 @@
<button type="button" class="btn btn-secondary" @click="cancelEdit"> <button type="button" class="btn btn-secondary" @click="cancelEdit">
{{ $t('admin.match3.cancel') }} {{ $t('admin.match3.cancel') }}
</button> </button>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" :disabled="!isLevelFormValid">
{{ $t('admin.match3.create') }} {{ $t('admin.match3.create') }}
</button> </button>
</div> </div>
@@ -553,6 +608,7 @@
<script> <script>
import SimpleTabs from '../../components/SimpleTabs.vue'; import SimpleTabs from '../../components/SimpleTabs.vue';
import apiClient from '../../utils/axios.js'; import apiClient from '../../utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js';
export default { export default {
name: 'AdminMinigamesView', name: 'AdminMinigamesView',
@@ -593,10 +649,29 @@ export default {
gridTemplateRows: `repeat(${this.levelForm.boardHeight}, 1fr)` gridTemplateRows: `repeat(${this.levelForm.boardHeight}, 1fr)`
}; };
}, },
isCreatingLevel() {
return this.selectedLevelId === 'new';
},
selectedLevel() { selectedLevel() {
if (this.selectedLevelId === 'new') return null; if (this.selectedLevelId === 'new') return null;
return this.levels.find(l => l.id === this.selectedLevelId); 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) { setTileType(index, tileType) {
console.log('setTileType called with:', index, tileType);
if (tileType === 'o') { if (tileType === 'o') {
// Leer
this.boardMatrix[index] = { active: false, tileType: 'o', index: index }; this.boardMatrix[index] = { active: false, tileType: 'o', index: index };
} else if (tileType === 'r') { } else if (tileType === 'r') {
// Zufällig
this.boardMatrix[index] = { active: true, tileType: 'r', index: index }; this.boardMatrix[index] = { active: true, tileType: 'r', index: index };
console.log('Set random tile at index:', index, this.boardMatrix[index]);
} else { } else {
// Spezifischer Tile-Typ
this.boardMatrix[index] = { active: true, tileType: tileType, index: index }; this.boardMatrix[index] = { active: true, tileType: tileType, index: index };
} }
this.selectedCellIndex = null; // Auswahl aufheben this.selectedCellIndex = null;
console.log('Board matrix after update:', this.boardMatrix);
}, },
// Mapping für Tile-Typen zu Zeichen // Mapping für Tile-Typen zu Zeichen
@@ -785,7 +854,6 @@ export default {
objectives: [] objectives: []
}; };
this.updateBoardMatrix(); this.updateBoardMatrix();
console.log('Bearbeitung abgebrochen, Objectives zurückgesetzt:', this.levelForm.objectives);
}, },
updateBoardMatrix() { updateBoardMatrix() {
@@ -905,6 +973,7 @@ export default {
...this.levelForm, ...this.levelForm,
boardLayout: this.generateBoardLayout() boardLayout: this.generateBoardLayout()
}; };
const wasCreating = this.selectedLevelId === 'new';
let savedLevel; let savedLevel;
if (this.selectedLevelId !== 'new') { if (this.selectedLevelId !== 'new') {
@@ -939,9 +1008,10 @@ export default {
this.selectedLevelId = 'new'; this.selectedLevelId = 'new';
this.selectedCellIndex = null; this.selectedCellIndex = null;
this.loadLevels(); this.loadLevels();
showSuccess(this, wasCreating ? 'Level wurde erstellt.' : 'Level wurde aktualisiert.');
} catch (error) { } catch (error) {
console.error('Fehler beim Speichern des Levels:', 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 { try {
await apiClient.delete(`/api/admin/minigames/match3/levels/${levelId}`); await apiClient.delete(`/api/admin/minigames/match3/levels/${levelId}`);
this.loadLevels(); this.loadLevels();
showSuccess(this, 'Level wurde geloescht.');
} catch (error) { } catch (error) {
console.error('Fehler beim Löschen des Levels:', 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; 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 { .section-header {
margin-bottom: 30px; margin-bottom: 30px;
text-align: center; text-align: center;
@@ -1055,6 +1215,8 @@ export default {
.level-dropdown { .level-dropdown {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 10px;
flex-wrap: wrap;
} }
.level-select { .level-select {
@@ -1072,6 +1234,15 @@ export default {
border-color: #F9A22C; 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 & Form */
.level-details, .level-details,
.level-form { .level-form {
@@ -1096,6 +1267,11 @@ export default {
margin: 0; margin: 0;
} }
.details-header p {
margin: 8px 0 0;
color: var(--color-text-secondary);
}
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -1480,6 +1656,16 @@ export default {
padding: 15px; padding: 15px;
} }
.workflow-hero {
flex-direction: column;
align-items: flex-start;
}
.workflow-grid,
.admin-summary-grid {
grid-template-columns: 1fr;
}
.form-row { .form-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -14,7 +14,7 @@
<tbody> <tbody>
<tr v-for="room in rooms" :key="room.id"> <tr v-for="room in rooms" :key="room.id">
<td>{{ room.title }}</td> <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>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
<td> <td>
<button @click="editRoom(room)">{{ $t('common.edit') }}</button> <button @click="editRoom(room)">{{ $t('common.edit') }}</button>
@@ -68,6 +68,14 @@ export default {
const res = await apiClient.get('/api/admin/chat/rooms'); const res = await apiClient.get('/api/admin/chat/rooms');
this.rooms = res.data; 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) { async saveRoom(roomData) {
// Remove forbidden and associated object fields before sending to backend // Remove forbidden and associated object fields before sending to backend
const { id, ownerId, passwordHash, roomType, genderRestriction, ...cleanData } = roomData; const { id, ownerId, passwordHash, roomType, genderRestriction, ...cleanData } = roomData;

View File

@@ -1,23 +1,35 @@
<template> <template>
<div class="admin-users"> <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> <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" /> <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> <h2>{{ selected.username }}</h2>
<label> <span class="edit__badge">{{ form.active ? 'Aktiv' : 'Gesperrt' }}</span>
{{ $t('admin.user.name') }} </div>
<label class="edit__field">
<span>{{ $t('admin.user.name') }}</span>
<input v-model="form.username" type="text" /> <input v-model="form.username" type="text" />
</label> </label>
<label>
{{ $t('admin.user.blocked') }} <label class="edit__toggle">
<input type="checkbox" :checked="!form.active" @change="toggleBlocked($event)" /> <input type="checkbox" :checked="!form.active" @change="toggleBlocked($event)" />
<span>{{ $t('admin.user.blocked') }}</span>
</label> </label>
<div class="actions"> <div class="actions">
<button @click="save">{{ $t('common.save') }}</button> <button @click="save">{{ $t('common.save') }}</button>
</div> </div>
</div> </section>
</div> </div>
</template> </template>
@@ -57,12 +69,105 @@ export default {
</script> </script>
<style scoped> <style scoped>
.admin-users { padding: 20px; } .admin-users {
.results table { width: 100%; border-collapse: collapse; } display: grid;
.results th, .results td { border: 1px solid #ddd; padding: 8px; } gap: 18px;
.edit { margin-top: 16px; display: grid; gap: 10px; max-width: 480px; } }
.actions { display: flex; gap: 8px; }
button { cursor: pointer; } .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> </style>

View File

@@ -4,9 +4,9 @@
<!-- Benutzer-Suche --> <!-- Benutzer-Suche -->
<div class="search-section"> <div class="search-section">
<label>{{ $t('admin.falukant.edituser.username') }}: <input type="text" v-model="user.username" @keyup.enter="searchUser" /></label> <label class="form-field">{{ $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> <label class="form-field">{{ $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> <button @click="searchUser" :disabled="!canSearch">{{ $t('admin.falukant.edituser.search') }}</button>
</div> </div>
<!-- Benutzer-Liste --> <!-- Benutzer-Liste -->
@@ -40,8 +40,8 @@
</select> </select>
</label> </label>
<div class="action-buttons"> <div class="action-buttons">
<button @click="saveUser">{{ $t('common.save') }}</button> <button @click="saveUser" :disabled="!hasUserChanges">{{ $t('common.save') }}</button>
<button @click="deleteUser">{{ $t('common.delete') }}</button> <button @click="deleteUser" class="button-secondary">{{ $t('common.delete') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -122,6 +122,7 @@ import { mapState } from 'vuex';
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import SimpleTabs from '@/components/SimpleTabs.vue'; import SimpleTabs from '@/components/SimpleTabs.vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default { export default {
name: 'AdminFalukantEditUserView', name: 'AdminFalukantEditUserView',
@@ -162,6 +163,15 @@ export default {
}, },
computed: { computed: {
...mapState('falukant', ['user']), ...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() { availableStockTypes() {
if (!this.newStock.branchId || !this.stockTypes.length) { if (!this.newStock.branchId || !this.stockTypes.length) {
return this.stockTypes; return this.stockTypes;
@@ -191,6 +201,10 @@ export default {
}, },
methods: { methods: {
async searchUser() { async searchUser() {
if (!this.canSearch) {
showError(this, 'Bitte Benutzername oder Charaktername eingeben.');
return;
}
const userResult = await apiClient.post('/api/admin/falukant/searchuser', { const userResult = await apiClient.post('/api/admin/falukant/searchuser', {
userName: this.user.username, userName: this.user.username,
characterName: this.user.characterName characterName: this.user.characterName
@@ -221,9 +235,9 @@ export default {
} }
try { try {
await apiClient.post(`/api/admin/falukant/edituser`, dataToChange); 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) { } catch (error) {
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.error'); showApiError(this, error, 'tr:admin.falukant.edituser.error');
} }
}, },
async deleteUser() { async deleteUser() {
@@ -245,7 +259,7 @@ export default {
this.userBranches = branchesResult.data; this.userBranches = branchesResult.data;
} catch (error) { } catch (error) {
console.error('Error loading user branches:', 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 { } finally {
this.loading.branches = false; this.loading.branches = false;
} }
@@ -255,7 +269,7 @@ export default {
await apiClient.put(`/api/admin/falukant/stock/${stock.id}`, { await apiClient.put(`/api/admin/falukant/stock/${stock.id}`, {
quantity: stock.quantity quantity: stock.quantity
}); });
this.$root.$refs.messageDialog.open('tr:admin.falukant.edituser.stockUpdated'); showSuccess(this, 'tr:admin.falukant.edituser.stockUpdated');
} catch (error) { } catch (error) {
console.error('Error updating stock:', error); console.error('Error updating stock:', error);
this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.errorUpdatingStock'); this.$root.$refs.errorDialog.open('tr:admin.falukant.edituser.errorUpdatingStock');

View File

@@ -1,29 +1,127 @@
<template> <template>
<div class="blog-list"> <div class="blog-list">
<section class="blog-list__hero surface-card">
<div>
<span class="blog-list__kicker">Community-Blogs</span>
<h1>Blogs</h1> <h1>Blogs</h1>
<p>Artikel, Projektstaende und persoenliche Einblicke aus der YourPart-Community.</p>
</div>
<div class="toolbar"> <div class="toolbar">
<router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link> <router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link>
</div> </div>
<div v-if="loading">Laden</div> </section>
<div v-else>
<div v-if="!blogs.length">Keine Blogs gefunden.</div> <div v-if="loading" class="blog-list__state surface-card">Laden</div>
<ul> <div v-else-if="!blogs.length" class="blog-list__state surface-card">Keine Blogs gefunden.</div>
<li v-for="b in blogs" :key="b.id"> <div v-else class="blog-grid">
<router-link :to="`/blogs/${b.id}`">{{ b.title }}</router-link> <article v-for="b in blogs" :key="b.id" class="blog-card surface-card">
<small> {{ b.owner?.username }}</small> <div class="blog-card__meta">von {{ b.owner?.username || 'Unbekannt' }}</div>
</li> <h2><router-link :to="blogUrl(b)">{{ b.title }}</router-link></h2>
</ul> <p>{{ blogExcerpt(b) }}</p>
<router-link class="blog-card__link" :to="blogUrl(b)">Zum Blog</router-link>
</article>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { listBlogs } from '@/api/blogApi.js'; import { listBlogs } from '@/api/blogApi.js';
import { createBlogSlug } from '@/utils/seo.js';
export default { export default {
name: 'BlogListView', name: 'BlogListView',
data: () => ({ blogs: [], loading: true }), data: () => ({ blogs: [], loading: true }),
async mounted() { async mounted() {
try { this.blogs = await listBlogs(); } finally { this.loading = false; } 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> </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>

View File

@@ -1,16 +1,24 @@
<template> <template>
<div class="blog-view"> <div class="blog-view">
<div v-if="loading">Laden</div> <div v-if="loading" class="blog-view__state surface-card">Laden</div>
<div v-else> <div v-else-if="blog" class="blog-layout">
<h1>{{ blog.title }}</h1> <section class="blog-hero surface-card">
<p v-if="blog.description">{{ blog.description }}</p> <div>
<div class="meta">von {{ blog.owner?.username }}</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"> <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> <router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
</div> </div>
<div class="posts"> </section>
<div class="blog-content">
<section class="posts surface-card">
<div class="posts__header">
<h2>{{ $t('blog.posts') }}</h2> <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"> <article v-for="p in items" :key="p.id" class="post">
<h3>{{ p.title }}</h3> <h3>{{ p.title }}</h3>
<div class="content" v-html="sanitize(p.content)" /> <div class="content" v-html="sanitize(p.content)" />
@@ -20,8 +28,8 @@
<span>{{ page }} / {{ pages }}</span> <span>{{ page }} / {{ pages }}</span>
<button :disabled="page===pages" @click="go(page+1)">»</button> <button :disabled="page===pages" @click="go(page+1)">»</button>
</div> </div>
</div> </section>
<div v-if="isOwner" class="post-editor"> <div v-if="isOwner" class="post-editor surface-card">
<h3>{{ $t('blog.newPost') }}</h3> <h3>{{ $t('blog.newPost') }}</h3>
<form @submit.prevent="addPost"> <form @submit.prevent="addPost">
<input v-model="newPost.title" :placeholder="$t('blog.title')" required /> <input v-model="newPost.title" :placeholder="$t('blog.title')" required />
@@ -31,12 +39,14 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
import { getBlog, listPosts, createPost } from '@/api/blogApi.js'; import { getBlog, listPosts, createPost } from '@/api/blogApi.js';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import RichTextEditor from './components/RichTextEditor.vue'; import RichTextEditor from './components/RichTextEditor.vue';
import { applySeo, buildAbsoluteUrl, createBlogSlug, stripHtml, truncateText } from '@/utils/seo.js';
export default { export default {
name: 'BlogView', name: 'BlogView',
props: { id: String, slug: String }, props: { id: String, slug: String },
@@ -46,9 +56,73 @@ export default {
isOwner() { isOwner() {
const u = this.$store.getters.user; const u = this.$store.getters.user;
return !!(u && this.blog && this.blog.owner && this.blog.owner.hashedId === u.id); 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() { 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 { try {
let id = this.$route.params.id; let id = this.$route.params.id;
// If we have a slug route param or the id is non-numeric, resolve to 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; const useId = id || this.resolvedId;
this.blog = await getBlog(useId); this.blog = await getBlog(useId);
await this.fetchPage(1); await this.fetchPage(1);
this.applyBlogSeo();
} catch (e) { } catch (e) {
console.log(e); console.log(e);
// this.$router.replace('/blogs'); // 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; } } finally { this.loading = false; }
}, },
methods: {
sanitize(html) { sanitize(html) {
return DOMPurify.sanitize(html || ''); return DOMPurify.sanitize(html || '');
}, },
@@ -83,8 +163,8 @@ export default {
this.page = res.page; this.page = res.page;
this.pageSize = res.pageSize; this.pageSize = res.pageSize;
this.total = res.total; 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 go(p) { if (p>=1 && p<=this.pages) await this.fetchPage(p); },
async addPost() { async addPost() {
if (!this.newPost.title || !this.newPost.content) return; if (!this.newPost.title || !this.newPost.content) return;
@@ -98,12 +178,109 @@ export default {
</script> </script>
<style lang="scss" scoped> <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 { .editbutton {
border: 1px solid #000; margin-bottom: 0;
background-color: #f9a22c;
margin-bottom: 1em;
border-radius: 3px;
padding: 0.2em 0.5em;
display: inline-block; 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> </style>

View File

@@ -2,7 +2,14 @@
<div class="contenthidden"> <div class="contenthidden">
<StatusBar ref="statusBar" /> <StatusBar ref="statusBar" />
<div class="contentscroll"> <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> <h2>{{ $t('falukant.branch.title') }}</h2>
<p>Produktion, Lager, Verkauf und Transport in einer spielweltbezogenen Steuerflaeche.</p>
</div>
</section>
<BranchSelection <BranchSelection
:branches="branches" :branches="branches"
@@ -310,6 +317,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
@@ -1110,8 +1118,49 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
h2 { .falukant-branch {
padding-top: 20px; 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 { .send-all-vehicles {
@@ -1161,11 +1210,12 @@ h2 {
} }
.modal-content { .modal-content {
background: white; background: rgba(255,255,255,0.98);
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: var(--radius-lg);
min-width: 400px; min-width: 400px;
max-width: 600px; max-width: 600px;
box-shadow: var(--shadow-medium);
} }
.send-vehicle-form { .send-vehicle-form {

View File

@@ -3,7 +3,13 @@
<StatusBar /> <StatusBar />
<div class="contentscroll family-layout"> <div class="contentscroll family-layout">
<div class="family-content"> <div class="family-content">
<section class="family-hero surface-card">
<div>
<span class="family-kicker">Familie</span>
<h2>{{ $t('falukant.family.title') }}</h2> <h2>{{ $t('falukant.family.title') }}</h2>
<p>Beziehungen, Kinder und familiäre Entwicklung in einer eigenen Spielweltansicht.</p>
</div>
</section>
<div class="spouse-section"> <div class="spouse-section">
<h3>{{ $t('falukant.family.spouse.title') }}</h3> <h3>{{ $t('falukant.family.spouse.title') }}</h3>
@@ -36,7 +42,7 @@
<td> <td>
<div class="progress"> <div class="progress">
<div class="progress-inner" :style="{ <div class="progress-inner" :style="{
width: relationships[0].progress + '%', width: normalizeWooingProgress(relationships[0].progress) + '%',
backgroundColor: progressColor(relationships[0].progress) backgroundColor: progressColor(relationships[0].progress)
}"></div> }"></div>
</div> </div>
@@ -200,6 +206,8 @@ import Character3D from '@/components/Character3D.vue'
import apiClient from '@/utils/axios.js' import apiClient from '@/utils/axios.js'
import { mapState } from 'vuex' import { mapState } from 'vuex'
const WOOING_PROGRESS_TARGET = 70
export default { export default {
name: 'FamilyView', name: 'FamilyView',
components: { components: {
@@ -342,6 +350,8 @@ export default {
}, },
async cancelWooing() { async cancelWooing() {
const confirmed = window.confirm(this.$t('falukant.family.spouse.wooing.cancelConfirm'));
if (!confirmed) return;
try { try {
await apiClient.post('/api/falukant/family/cancel-wooing'); await apiClient.post('/api/falukant/family/cancel-wooing');
await this.loadFamilyData(); await this.loadFamilyData();
@@ -409,11 +419,16 @@ export default {
}, },
progressColor(p) { 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 red = Math.round(255 * (1 - pct));
const green = Math.round(255 * pct); const green = Math.round(255 * pct);
return `rgb(${red}, ${green}, 0)`; 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() { jumpToPartyForm() {
this.$router.push({ this.$router.push({
@@ -469,7 +484,33 @@ export default {
display: flex; display: flex;
gap: 20px; gap: 20px;
align-items: flex-start; 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 { .self-character-3d {
@@ -483,15 +524,20 @@ export default {
.family-content { .family-content {
flex: 1; flex: 1;
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
} }
.spouse-section, .spouse-section,
.children-section, .children-section,
.lovers-section { .lovers-section {
border: 1px solid #ccc; border: 1px solid var(--color-border);
margin: 10px 0; margin: 12px 0;
border-radius: 4px; border-radius: var(--radius-lg);
padding: 10px; padding: 16px;
background: rgba(255, 252, 247, 0.86);
box-shadow: var(--shadow-soft);
} }
.relationship-container { .relationship-container {
@@ -513,8 +559,8 @@ export default {
.partner-character-3d { .partner-character-3d {
width: 200px; width: 200px;
height: 280px; height: 280px;
border: 1px solid #ccc; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-lg);
background-color: #fdf1db; background-color: #fdf1db;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -537,8 +583,8 @@ export default {
.child-character-3d { .child-character-3d {
width: 200px; width: 200px;
height: 280px; height: 280px;
border: 1px solid #ccc; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-lg);
background-color: #fdf1db; background-color: #fdf1db;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -597,10 +643,6 @@ export default {
width: 50px; width: 50px;
} }
h2 {
padding-top: 20px;
}
.relationship>table, .relationship>table,
.relationship>ul { .relationship>ul {
display: inline-block; display: inline-block;
@@ -648,4 +690,11 @@ h2 {
.set-heir-button:hover { .set-heir-button:hover {
background-color: #218838; background-color: #218838;
} }
@media (max-width: 960px) {
.relationship-row,
.children-container {
flex-direction: column;
}
}
</style> </style>

View File

@@ -1,7 +1,46 @@
<template> <template>
<div> <div class="falukant-overview">
<StatusBar /> <StatusBar />
<section class="falukant-hero surface-card">
<div>
<span class="falukant-kicker">Falukant</span>
<h2>{{ $t('falukant.overview.title') }}</h2> <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 --> <!-- Erben-Auswahl wenn kein Charakter vorhanden -->
<div v-if="!falukantUser?.character" class="heir-selection-container"> <div v-if="!falukantUser?.character" class="heir-selection-container">
@@ -16,7 +55,7 @@
<div class="heir-info"> <div class="heir-info">
<div class="heir-name"> <div class="heir-name">
{{ $t(`falukant.titles.${heir.gender}.noncivil`) }} {{ $t(`falukant.titles.${heir.gender}.noncivil`) }}
{{ heir.definedFirstName.name }} {{ heir.definedLastName.name }} {{ heir.definedFirstName?.name || '---' }} {{ heir.definedLastName?.name || '' }}
</div> </div>
<div class="heir-age">{{ $t('falukant.overview.metadata.age') }}: {{ heir.age }}</div> <div class="heir-age">{{ $t('falukant.overview.metadata.age') }}: {{ heir.age }}</div>
</div> </div>
@@ -136,6 +175,7 @@
import StatusBar from '@/components/falukant/StatusBar.vue'; import StatusBar from '@/components/falukant/StatusBar.vue';
import Character3D from '@/components/Character3D.vue'; import Character3D from '@/components/Character3D.vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
const AVATAR_POSITIONS = { const AVATAR_POSITIONS = {
@@ -233,6 +273,50 @@ export default {
const m = this.falukantUser?.money; const m = this.falukantUser?.money;
return typeof m === 'string' ? parseFloat(m) : m; 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() { locale() {
return window.navigator.language || 'en-US'; return window.navigator.language || 'en-US';
}, },
@@ -369,6 +453,16 @@ export default {
openBranch(branchId) { openBranch(branchId) {
this.$router.push({ name: 'BranchView', params: { 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() { async fetchProductions() {
try { try {
const response = await apiClient.get('/api/falukant/productions'); const response = await apiClient.get('/api/falukant/productions');
@@ -381,13 +475,6 @@ export default {
return new Date(timestamp).toLocaleString(); return new Date(timestamp).toLocaleString();
}, },
async fetchPotentialHeirs() { 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; this.loadingHeirs = true;
try { try {
const response = await apiClient.get('/api/falukant/heirs/potential'); const response = await apiClient.get('/api/falukant/heirs/potential');
@@ -406,15 +493,15 @@ export default {
async selectHeir(heirId) { async selectHeir(heirId) {
try { try {
await apiClient.post('/api/falukant/heirs/select', { heirId }); await apiClient.post('/api/falukant/heirs/select', { heirId });
// Lade User-Daten neu
await this.fetchFalukantUser(); await this.fetchFalukantUser();
if (this.falukantUser?.character) { if (this.falukantUser?.character) {
await this.fetchAllStock(); await this.fetchAllStock();
await this.fetchProductions(); await this.fetchProductions();
} }
showSuccess(this, 'Erbe wurde uebernommen.');
} catch (error) { } catch (error) {
console.error('Error selecting heir:', 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> </script>
<style scoped lang="scss"> <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 { .overviewcontainer {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr; grid-template-columns: repeat(4, minmax(0, 1fr));
grid-gap: 5px; gap: 12px;
} }
.overviewcontainer>div { .overviewcontainer>div {
border: 1px solid #ccc; border: 1px solid var(--color-border);
padding: 5px; padding: 16px;
border-radius: 4px; border-radius: var(--radius-lg);
background: rgba(255, 253, 249, 0.82);
box-shadow: var(--shadow-soft);
} }
.imagecontainer { .imagecontainer {
@@ -445,10 +615,12 @@ export default {
} }
.avatar { .avatar {
border: 1px solid #ccc; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-lg);
background-color: rgba(255,255,255,0.72);
background-repeat: no-repeat; background-repeat: no-repeat;
image-rendering: crisp-edges; image-rendering: crisp-edges;
box-shadow: var(--shadow-soft);
} }
.house-with-character { .house-with-character {
@@ -460,8 +632,8 @@ export default {
.house { .house {
position: absolute; position: absolute;
inset: 0; inset: 0;
border: 1px solid #ccc; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-lg);
background-repeat: no-repeat; background-repeat: no-repeat;
image-rendering: crisp-edges; image-rendering: crisp-edges;
z-index: 1; z-index: 1;
@@ -477,16 +649,13 @@ export default {
z-index: 2; z-index: 2;
} }
h2 {
padding-top: 20px;
}
.heir-selection-container { .heir-selection-container {
border: 2px solid #dc3545; border: 1px solid rgba(177, 59, 53, 0.18);
border-radius: 8px; border-radius: var(--radius-lg);
padding: 20px; padding: 20px;
margin: 20px 0; margin: 20px 0;
background-color: #fff3cd; background-color: rgba(255, 243, 205, 0.92);
box-shadow: var(--shadow-soft);
} }
.heir-selection-container h3 { .heir-selection-container h3 {
@@ -502,10 +671,10 @@ h2 {
} }
.heir-card { .heir-card {
border: 1px solid #ccc; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: var(--radius-lg);
padding: 15px; padding: 15px;
background-color: white; background-color: rgba(255,255,255,0.86);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -525,6 +694,20 @@ h2 {
font-size: 0.9em; 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 { .select-heir-button {
background-color: #28a745; background-color: #28a745;
color: white; color: white;
@@ -542,6 +725,16 @@ h2 {
.loading, .no-heirs { .loading, .no-heirs {
text-align: center; text-align: center;
padding: 20px; padding: 20px;
color: #666; color: var(--color-text-secondary);
}
@media (max-width: 960px) {
.overviewcontainer {
grid-template-columns: 1fr;
}
.imagecontainer {
flex-direction: column;
}
} }
</style> </style>

View File

@@ -1,9 +1,14 @@
<template> <template>
<div class="home-logged-in"> <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> <h1>Willkommen zurück!</h1>
<p class="dashboard-subtitle">Schön, dass du wieder da bist.</p> <p class="dashboard-subtitle">
<div class="dashboard-toolbar"> Dein persönlicher Einstieg in Community, Termine, Falukant und laufende Aktivitäten.
</p>
</div>
<div class="dashboard-toolbar surface-card">
<button <button
v-if="!editMode" v-if="!editMode"
type="button" type="button"
@@ -42,7 +47,25 @@
</button> </button>
</template> </template>
</div> </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 <div
v-if="loadError" v-if="loadError"
@@ -58,6 +81,15 @@
</div> </div>
<div <div
v-else 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" ref="dashboardGridRef"
class="dashboard-grid" class="dashboard-grid"
@dragover.prevent @dragover.prevent
@@ -104,6 +136,7 @@
</div> </div>
</template> </template>
</div> </div>
</div>
<div v-if="widgets.length === 0 && !loading" class="dashboard-empty"> <div v-if="widgets.length === 0 && !loading" class="dashboard-empty">
<p>Noch keine Widgets. Klicke auf „Dashboard bearbeiten“ und dann „+ Widget hinzufügen“.</p> <p>Noch keine Widgets. Klicke auf „Dashboard bearbeiten“ und dann „+ Widget hinzufügen“.</p>
@@ -306,98 +339,170 @@ export default {
<style scoped> <style scoped>
.home-logged-in { .home-logged-in {
max-width: 1200px; max-width: var(--content-max-width);
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 8px 0 24px;
} }
.dashboard-header { .dashboard-hero {
margin-bottom: 24px; 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 { .dashboard-hero__copy {
color: #333; max-width: 640px;
margin: 0 0 4px 0; }
.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 { .dashboard-subtitle {
color: #666; color: var(--color-text-secondary);
margin: 0 0 16px 0; 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 { .dashboard-toolbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
align-self: flex-start;
flex-wrap: wrap;
gap: 10px; gap: 10px;
padding: 14px;
min-width: 300px;
background: rgba(255, 255, 255, 0.72);
} }
.btn-edit, .btn-edit,
.btn-done { .btn-done {
padding: 8px 14px; min-height: 40px;
border-radius: 4px;
border: 1px solid transparent;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
cursor: pointer;
font-size: 0.9rem;
} }
.btn-edit:hover, .btn-edit:hover,
.btn-done:hover { .btn-done:hover {
background: var(--color-primary-orange-light); color: #2b1f14;
color: var(--color-text-secondary);
border-color: var(--color-text-secondary);
} }
.widget-add-row { .widget-add-row {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 10px; gap: 10px;
} }
.btn-add-again { .btn-add-again {
padding: 8px 14px; min-height: 40px;
border-radius: 4px; background: rgba(255, 255, 255, 0.78);
border: 1px solid var(--color-text-secondary); border-color: var(--color-border-strong);
background: #fff; box-shadow: none;
color: var(--color-text-primary);
font-size: 0.9rem;
cursor: pointer;
} }
.btn-add-again:hover { .btn-add-again:hover {
background: var(--color-primary-orange-light); background: rgba(255, 255, 255, 0.96);
border-color: var(--color-primary-orange);
} }
.widget-type-select { .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; min-width: 180px;
} }
.dashboard-message { .dashboard-message {
padding: 16px; padding: 16px 18px;
border-radius: 8px; border-radius: var(--radius-md);
margin-bottom: 16px; margin-bottom: 16px;
} }
.dashboard-error { .dashboard-error {
background: #f8d7da; background: rgba(177, 59, 53, 0.12);
color: #721c24; color: #7a241f;
border: 1px solid #f5c6cb; 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 { .dashboard-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-auto-rows: 200px; grid-auto-rows: 220px;
gap: 20px; gap: 18px;
} }
.dashboard-grid-cell { .dashboard-grid-cell {
@@ -415,9 +520,9 @@ export default {
} }
.dashboard-grid-cell.drop-target { .dashboard-grid-cell.drop-target {
outline: 2px dashed #0d6efd; outline: 2px dashed rgba(248, 162, 43, 0.82);
outline-offset: 4px; outline-offset: 4px;
border-radius: 8px; border-radius: var(--radius-md);
} }
.dashboard-grid-cell.drag-source { .dashboard-grid-cell.drag-source {
@@ -426,13 +531,14 @@ export default {
.dashboard-widget-edit { .dashboard-widget-edit {
min-height: 200px; min-height: 200px;
padding: 12px; padding: 16px;
background: #f8f9fa; background: rgba(255, 255, 255, 0.82);
border: 1px solid #dee2e6; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-md);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
box-shadow: var(--shadow-soft);
} }
.widget-edit-fields { .widget-edit-fields {
@@ -441,40 +547,58 @@ export default {
gap: 8px; gap: 8px;
} }
.widget-edit-input {
padding: 8px 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9rem;
}
.btn-remove { .btn-remove {
align-self: flex-start; align-self: flex-start;
padding: 6px 12px; min-height: 36px;
border: 1px solid transparent; background: rgba(177, 59, 53, 0.12);
background: var(--color-primary-orange); color: #7a241f;
color: var(--color-text-on-orange); border-color: rgba(177, 59, 53, 0.18);
border-radius: 4px; box-shadow: none;
cursor: pointer;
font-size: 0.85rem;
} }
.btn-remove:hover { .btn-remove:hover {
background: var(--color-primary-orange-light); background: rgba(177, 59, 53, 0.18);
color: var(--color-text-secondary);
border-color: var(--color-text-secondary);
} }
.dashboard-empty { .dashboard-empty {
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: #666; color: var(--color-text-secondary);
background: #f8f9fa; background: rgba(255, 255, 255, 0.72);
border-radius: 8px; border-radius: var(--radius-lg);
border: 1px dashed #dee2e6; border: 1px dashed var(--color-border-strong);
box-shadow: var(--shadow-soft);
} }
.actions { .actions {
margin-top: 30px; 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> </style>

View File

@@ -8,20 +8,24 @@
<Character3D gender="male" /> <Character3D gender="male" />
</div> </div>
<div class="actions"> <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> <h2>{{ $t('home.nologin.welcome') }}</h2>
<p>{{ $t('home.nologin.description') }}</p> <p>{{ $t('home.nologin.description') }}</p>
</div>
<div class="story-highlight">
<p> <p>
YourPart ist eine wachsende OnlinePlattform, die CommunityFunktionen, EchtzeitChat, Foren, YourPart verbindet Community, Echtzeit-Chat, Foren, Bildergalerie und das Aufbauspiel
ein soziales Netzwerk mit Bildergalerie sowie das Aufbauspiel <em>Falukant</em> vereint. <em>Falukant</em> in einer Plattform. Der Fokus liegt auf Austausch, spielerischer Tiefe und
Aktuell befindet sich die Seite in der BetaPhase wir erweitern Funktionen, Inhalte und einer wachsenden Produktwelt.
Stabilität
kontinuierlich.
</p> </p>
</div>
<div class="story-block">
<h3>{{ $t('home.nologin.expected.title') }}</h3> <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.chat')"></li>
<li v-html="$t('home.nologin.expected.items.social')"></li> <li v-html="$t('home.nologin.expected.items.social')"></li>
<li v-html="$t('home.nologin.expected.items.forum')"></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.minigames')"></li>
<li v-html="$t('home.nologin.expected.items.multilingual')"></li> <li v-html="$t('home.nologin.expected.items.multilingual')"></li>
</ul> </ul>
</div>
<div class="story-columns">
<article>
<h3>{{ $t('home.nologin.falukantShort.title') }}</h3> <h3>{{ $t('home.nologin.falukantShort.title') }}</h3>
<p>{{ $t('home.nologin.falukantShort.text') }}</p> <p>{{ $t('home.nologin.falukantShort.text') }}</p>
</article>
<article>
<h3>{{ $t('home.nologin.privacyBeta.title') }}</h3> <h3>{{ $t('home.nologin.privacyBeta.title') }}</h3>
<p>{{ $t('home.nologin.privacyBeta.text') }}</p> <p>{{ $t('home.nologin.privacyBeta.text') }}</p>
</article>
</div>
<div class="story-cta">
<h3>{{ $t('home.nologin.getStarted.title') }}</h3> <h3>{{ $t('home.nologin.getStarted.title') }}</h3>
<p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p> <p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p>
</div> </div>
<div> </section>
<div> <section class="actions-panel actions-panel--access surface-card">
<div> <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')" <input v-model="username" size="20" type="text" :placeholder="$t('home.nologin.login.name')"
:title="$t('home.nologin.login.namedescription')" @keydown.enter="focusPassword"> :title="$t('home.nologin.login.namedescription')" @keydown.enter="focusPassword">
</div>
<div>
<input v-model="password" size="20" type="password" <input v-model="password" size="20" type="password"
:placeholder="$t('home.nologin.login.password')" :placeholder="$t('home.nologin.login.password')"
:title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin" :title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin"
ref="passwordInput"> ref="passwordInput">
</div> </div>
<div> <div class="stay-logged-in-row">
<label><input type="checkbox"><span>{{ $t('home.nologin.login.stayLoggedIn') }}</span></label> <label class="stay-logged-in-label">
<input class="stay-logged-in-checkbox" type="checkbox">
<span>{{ $t('home.nologin.login.stayLoggedIn') }}</span>
</label>
</div> </div>
<button type="button" class="primary-action" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
</div> </div>
<div>
<button type="button" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button> <div class="access-split">
</div> <article class="access-card">
<div> <h3>{{ $t('home.nologin.randomchat') }}</h3>
<h2>{{ $t('home.nologin.randomchat') }}</h2> <p>Ohne lange Vorbereitung direkt in spontane Begegnungen und offene Gespraeche starten.</p>
<button @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button> <button type="button" class="secondary-action" @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
</div> </article>
<div> <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">{{ <span @click="openPasswordResetDialog" class="link">{{
$t('home.nologin.login.lostpassword') }}</span> | <span id="o1p5iry1" $t('home.nologin.login.lostpassword') }}</span>
@click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span> <span @click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
</div> </div>
</article>
</div> </div>
</section>
</div> </div>
<div class="mascot"> <div class="mascot">
<Character3D gender="female" /> <Character3D gender="female" />
@@ -125,7 +146,8 @@ export default {
const response = await apiClient.post('/api/auth/login', { username: this.username, password: this.password }); const response = await apiClient.post('/api/auth/login', { username: this.username, password: this.password });
this.login(response.data); this.login(response.data);
} catch (error) { } 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> <style scoped>
.beta-banner { .beta-banner {
background: #fff3cd; width: min(100%, var(--content-max-width));
border: 1px solid #ffeeba; background: linear-gradient(180deg, #fff2cf 0%, #fde7b2 100%);
color: #856404; border: 1px solid rgba(201, 130, 31, 0.24);
color: #8a5a12;
padding: 10px 14px; padding: 10px 14px;
margin: 0 0 12px 0; margin: 0 0 14px 0;
text-align: center; text-align: center;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
} }
.home-structure { .home-structure {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
justify-content: center; justify-content: center;
overflow: hidden; gap: 1.4rem;
gap: 2em; width: 100%;
height: 100%; height: 100%;
flex: 1;
min-height: 0;
} }
.home-structure>div { .home-structure>div {
flex: 1;
text-align: center; text-align: center;
display: flex; display: flex;
min-height: 0;
} }
.mascot { .mascot {
flex: 0 0 clamp(180px, 22%, 280px);
justify-content: center; justify-content: center;
align-items: center; align-items: stretch;
background-color: #fdf1db; background: linear-gradient(180deg, #fff5e8 0%, #fce7ca 100%);
width: 80%; border: 1px solid rgba(248, 162, 43, 0.16);
height: 80%; border-radius: var(--radius-lg);
min-height: 400px; 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 { .actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2em; gap: 1rem;
flex: 1 1 auto;
min-height: 0;
} }
.actions>div { .actions-panel {
flex: 1; flex: 1;
background-color: #FFF4F0; min-height: 0;
align-items: center; background:
justify-content: flex-start; linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(248, 240, 231, 0.96) 100%);
display: flex;
color: #5D4037; color: #5D4037;
display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: auto;
padding: 0.5rem; padding: 1.2rem 1.25rem;
text-align: left;
} }
.actions>div>h2 { .actions-panel h2,
color: var(--color-primary-orange); .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 { .seo-content {
@@ -233,7 +394,37 @@ export default {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
width: 100%;
height: 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> </style>

View File

@@ -1,11 +1,36 @@
<template> <template>
<div class="contenthidden"> <div class="contenthidden">
<div class="contentscroll"> <div class="contentscroll match3-view">
<!-- Spiel-Titel --> <!-- 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> <h1>{{ $t('minigames.match3.title') }}</h1>
<p>{{ $t('minigames.match3.campaignDescription') }}</p> <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>
<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 --> <!-- Kampagnen-Status -->
<div class="game-layout"> <div class="game-layout">
@@ -45,13 +70,13 @@
<div class="game-content"> <div class="game-content">
<!-- Verbleibende Züge --> <!-- 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-label">{{ $t('minigames.match3.movesLeft') }}:</span>
<span class="moves-left-value">{{ safeMovesLeft }}</span> <span class="moves-left-value">{{ safeMovesLeft }}</span>
</div> </div>
<!-- Level-Info --> <!-- 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">
<div class="level-header-content"> <div class="level-header-content">
<h3 class="level-title"> <h3 class="level-title">
@@ -6010,6 +6035,42 @@ export default {
}, },
safeMovesLeft() { safeMovesLeft() {
return this.movesLeft || 0; 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 */ /* Minimalistischer Style - nur für Match3Game */
/* Verwendet globale Scroll-Klassen: .contenthidden und .contentscroll */ /* Verwendet globale Scroll-Klassen: .contenthidden und .contentscroll */
.match3-view {
padding-bottom: 24px;
}
.game-title { .game-title {
text-align: center; text-align: center;
margin-bottom: 30px; margin: 16px auto 30px;
padding-top: 20px; 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 { .game-title h1 {
margin: 0 0 10px 0; margin: 0 0 10px 0;
font-size: 2rem; font-size: 2rem;
font-weight: 600; font-weight: 600;
color: #333; color: var(--color-text-primary);
} }
.game-title p { .game-title p {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.1rem;
color: #666; color: var(--color-text-secondary);
line-height: 1.5; 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 */ /* Spiel-Layout */
.game-layout { .game-layout {
display: flex; display: flex;
@@ -6062,12 +6191,12 @@ export default {
/* Verbleibende Züge Anzeige */ /* Verbleibende Züge Anzeige */
.moves-left-display { .moves-left-display {
background: white; background: rgba(255, 251, 246, 0.96);
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-md);
padding: 5px; padding: 8px 14px;
margin-bottom: 20px; margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: var(--shadow-soft);
text-align: center; text-align: center;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6078,7 +6207,7 @@ export default {
.moves-left-label { .moves-left-label {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: #333; color: var(--color-text-secondary);
} }
.moves-left-value { .moves-left-value {
@@ -6089,17 +6218,17 @@ export default {
/* Statistik-Bereich */ /* Statistik-Bereich */
.stats-card { .stats-card {
background: white; background: rgba(255, 251, 246, 0.96);
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-md);
padding: 5px; padding: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: var(--shadow-soft);
} }
.stats-header { .stats-header {
cursor: pointer; cursor: pointer;
padding: 8px; padding: 8px;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--color-border);
margin-bottom: 10px; margin-bottom: 10px;
user-select: none; user-select: none;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@@ -6107,7 +6236,7 @@ export default {
} }
.stats-header:hover { .stats-header:hover {
background-color: #f8f9fa; background-color: rgba(248, 162, 43, 0.08);
} }
.stats-header-content { .stats-header-content {
@@ -6121,7 +6250,7 @@ export default {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--color-text-primary);
pointer-events: none; pointer-events: none;
} }
@@ -6174,7 +6303,7 @@ export default {
.stat-label { .stat-label {
font-size: 12px; font-size: 12px;
color: #666; color: var(--color-text-secondary);
} }
/* Statistik-Werte Farben */ /* Statistik-Werte Farben */
@@ -6185,12 +6314,12 @@ export default {
/* Level-Info */ /* Level-Info */
.level-info-card { .level-info-card {
background: white; background: rgba(255, 251, 246, 0.96);
border: 1px solid #ddd; border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-lg);
padding: 5px; padding: 8px;
margin-bottom: 20px; margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: var(--shadow-soft);
width: 100%; width: 100%;
max-width: 600px; max-width: 600px;
} }
@@ -6198,7 +6327,7 @@ export default {
.level-header { .level-header {
cursor: pointer; cursor: pointer;
padding: 8px; padding: 8px;
border-bottom: 1px solid #eee; border-bottom: 1px solid var(--color-border);
margin-bottom: 10px; margin-bottom: 10px;
user-select: none; user-select: none;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@@ -6206,7 +6335,7 @@ export default {
} }
.level-header:hover { .level-header:hover {
background-color: #f8f9fa; background-color: rgba(248, 162, 43, 0.08);
} }
.level-header-content { .level-header-content {
@@ -6220,7 +6349,7 @@ export default {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 600;
color: #333; color: var(--color-text-primary);
flex-grow: 1; flex-grow: 1;
text-align: left; text-align: left;
pointer-events: none; pointer-events: none;
@@ -6233,7 +6362,7 @@ export default {
.level-info-card p { .level-info-card p {
margin: 0 0 15px 0; margin: 0 0 15px 0;
text-align: left; text-align: left;
color: #666; color: var(--color-text-secondary);
line-height: 1.6; line-height: 1.6;
} }
@@ -6248,8 +6377,8 @@ export default {
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
padding: 8px; padding: 8px;
background-color: #f8f9fa; background-color: rgba(255, 255, 255, 0.64);
border-radius: 4px; border-radius: var(--radius-sm);
} }
.objective-icon { .objective-icon {
@@ -6265,7 +6394,7 @@ export default {
.objective-progress { .objective-progress {
margin-left: auto; margin-left: auto;
font-size: 12px; font-size: 12px;
color: #6c757d; color: var(--color-text-muted);
font-weight: 500; font-weight: 500;
} }
@@ -6273,10 +6402,10 @@ export default {
.game-board-container { .game-board-container {
display: inline-block; display: inline-block;
padding: 20px; padding: 20px;
background: white; background: rgba(255, 251, 246, 0.96);
border-radius: 8px; border-radius: var(--radius-lg);
border: 1px solid #ddd; border: 1px solid var(--color-border);
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: var(--shadow-soft);
margin-bottom: 20px; margin-bottom: 20px;
position: relative; /* Für absolute Positionierung der Animationen */ position: relative; /* Für absolute Positionierung der Animationen */
} }
@@ -6461,6 +6590,17 @@ export default {
margin-bottom: 20px; margin-bottom: 20px;
} }
@media (max-width: 760px) {
.game-title {
margin-top: 12px;
padding: 22px 18px;
}
.play-focus {
padding: 18px;
}
}
.btn { .btn {
display: inline-block; display: inline-block;
padding: 10px 20px; padding: 10px 20px;
@@ -6875,4 +7015,3 @@ export default {
} }
} }
</style> </style>

View File

@@ -1,9 +1,15 @@
<template> <template>
<div class="calendar-view"> <div class="calendar-view">
<section class="calendar-hero surface-card">
<div>
<span class="calendar-kicker">Planung</span>
<h2>{{ $t('personal.calendar.title') }}</h2> <h2>{{ $t('personal.calendar.title') }}</h2>
<p>Termine, Geburtstage und eigene Eintraege in einer strukturierten Uebersicht.</p>
</div>
</section>
<!-- Toolbar --> <!-- Toolbar -->
<div class="calendar-toolbar"> <div class="calendar-toolbar surface-card">
<div class="nav-buttons"> <div class="nav-buttons">
<button @click="openNewEventDialog()" class="btn-new-event"> <button @click="openNewEventDialog()" class="btn-new-event">
+ {{ $t('personal.calendar.newEntry') }} + {{ $t('personal.calendar.newEntry') }}
@@ -27,7 +33,7 @@
</div> </div>
<!-- Selection info --> <!-- 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 }) }} {{ $t('personal.calendar.selectedDays', { count: selectedDates.length }) }}
<button @click="createEventFromSelection" class="btn-create-from-selection"> <button @click="createEventFromSelection" class="btn-create-from-selection">
{{ $t('personal.calendar.createEventForSelection') }} {{ $t('personal.calendar.createEventForSelection') }}
@@ -839,16 +845,39 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
.calendar-view { .calendar-view {
padding: 20px; padding: 0 0 24px;
max-width: 1200px; max-width: var(--content-max-width);
margin: 0 auto; margin: 0 auto;
} }
h2 { .calendar-hero {
margin-bottom: 20px; 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); color: var(--color-text-primary);
} }
.calendar-hero p {
margin: 0;
color: var(--color-text-secondary);
}
// Toolbar // Toolbar
.calendar-toolbar { .calendar-toolbar {
display: flex; display: flex;
@@ -857,6 +886,7 @@ h2 {
margin-bottom: 20px; margin-bottom: 20px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
padding: 16px 18px;
} }
.nav-buttons { .nav-buttons {
@@ -1471,7 +1501,7 @@ h2 {
.category-btn { .category-btn {
padding: 6px 12px; padding: 6px 12px;
border: 2px solid; border: 2px solid;
border-radius: 20px; border-radius: var(--radius-lg);
cursor: pointer; cursor: pointer;
font-size: 0.85em; font-size: 0.85em;
font-weight: 500; font-weight: 500;

View 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>

View 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>

View 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>

View File

@@ -1,45 +1,66 @@
<template> <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> <h2>{{ $t("settings.account.title") }}</h2>
<div> <p>Benutzername, E-Mail, Passwort und Sichtbarkeit an einer Stelle pflegen.</p>
<label><span>{{ $t("settings.account.username") }} </span><input type="text" v-model="username" </section>
:placeholder="$t('settings.account.username')" /></label>
<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>
<div>
<label><span>{{ $t("settings.account.email") }} </span><input type="text" v-model="email" <label class="account-settings__toggle">
:placeholder="$t('settings.account.email')" /></label> <input type="checkbox" v-model="showInSearch" />
</div> <span>{{ $t("settings.account.showinsearch") }}</span>
<div> </label>
<label><span>{{ $t("settings.account.newpassword") }} </span><input type="password" v-model="newpassword"
:placeholder="$t('settings.account.newpassword')" autocomplete="new-password" /></label> <div class="account-settings__actions">
</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>
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button> <button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
</div> </div>
<div> </section>
<label><input type="checkbox" v-model="showInSearch" /> {{ $t("settings.account.showinsearch") }}</label>
</div>
</div> </div>
</template> </template>
<script> <script>
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default { export default {
name: "AccountSettingsView", name: "AccountSettingsView",
components: {}, components: {},
computed: {
...mapGetters(['user']),
},
data() { data() {
return { return {
username: "", username: "",
@@ -50,6 +71,18 @@ export default {
oldpassword: "", 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: { methods: {
async changeAccount() { async changeAccount() {
try { try {
@@ -57,15 +90,19 @@ export default {
const hasNewPassword = this.newpassword && this.newpassword.trim() !== ''; const hasNewPassword = this.newpassword && this.newpassword.trim() !== '';
if (hasNewPassword) { 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 // Validiere Passwort-Wiederholung nur wenn ein neues Passwort eingegeben wurde
if (this.newpassword !== this.newpasswordretype) { if (!this.passwordsMatch) {
alert('Die Passwörter stimmen nicht überein.'); showError(this, 'Die Passwoerter stimmen nicht ueberein.');
return; return;
} }
// Prüfe ob das alte Passwort eingegeben wurde // Prüfe ob das alte Passwort eingegeben wurde
if (!this.oldpassword || this.oldpassword.trim() === '') { 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; return;
} }
} }
@@ -89,7 +126,7 @@ export default {
// API-Aufruf zum Speichern der Account-Einstellungen // API-Aufruf zum Speichern der Account-Einstellungen
await apiClient.post('/api/settings/set-account', accountData); 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 // Leere die Passwort-Felder nach erfolgreichem Speichern
this.newpassword = ''; this.newpassword = '';
@@ -98,17 +135,12 @@ export default {
} catch (error) { } catch (error) {
console.error('Fehler beim Speichern der Account-Einstellungen:', error); console.error('Fehler beim Speichern der Account-Einstellungen:', error);
if (error.response && error.response.data && error.response.data.error) { showApiError(this, error, 'Ein Fehler ist beim Speichern der Account-Einstellungen aufgetreten.');
alert('Fehler: ' + error.response.data.error);
} else {
alert('Ein Fehler ist aufgetreten beim Speichern der Account-Einstellungen.');
}
} }
} }
}, },
async mounted() { async mounted() {
const response = await apiClient.post('/api/settings/account', { userId: this.user.id }); const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
console.log(response.data);
this.username = response.data.username; this.username = response.data.username;
this.showInSearch = response.data.showinsearch; this.showInSearch = response.data.showinsearch;
this.email = response.data.email; this.email = response.data.email;
@@ -117,18 +149,101 @@ export default {
this.newpassword = ''; this.newpassword = '';
this.newpasswordretype = ''; this.newpasswordretype = '';
this.oldpassword = ''; this.oldpassword = '';
console.log(this.showInSearch);
}, },
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
label { .account-settings {
white-space: nowrap; display: grid;
gap: 18px;
max-width: 960px;
} }
label > span {
width: 15em; .account-settings__hero,
display: inline-block; .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%;
}
} }
</style> </style>

View File

@@ -1,7 +1,14 @@
<template> <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> <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> <h3>{{ isEditing ? $t('socialnetwork.diary.editEntry') : $t('socialnetwork.diary.newEntry') }}</h3>
<textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea> <textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea>
<div class="form-actions"> <div class="form-actions">
@@ -9,11 +16,11 @@
}}</button> }}</button>
<button v-if="isEditing" @click="cancelEdit">{{ $t('socialnetwork.diary.cancel') }}</button> <button v-if="isEditing" @click="cancelEdit">{{ $t('socialnetwork.diary.cancel') }}</button>
</div> </div>
</div> </section>
<div v-if="diaryEntries.length === 0">{{ $t('socialnetwork.diary.noEntries') }}</div> <div v-if="diaryEntries.length === 0" class="diary-empty surface-card">{{ $t('socialnetwork.diary.noEntries') }}</div>
<div v-else class="diary-entries"> <section v-else class="diary-entries">
<div v-for="entry in diaryEntries" :key="entry.id" class="diary-entry"> <article v-for="entry in diaryEntries" :key="entry.id" class="diary-entry surface-card">
<p v-html="sanitizedText(entry)"></p> <p v-html="sanitizedText(entry)"></p>
<div class="entry-info"> <div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span> <span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
@@ -22,10 +29,10 @@
<span @click="deleteEntry(entry.id)" class="button" :title="$t('socialnetwork.diary.delete')"></span> <span @click="deleteEntry(entry.id)" class="button" :title="$t('socialnetwork.diary.delete')"></span>
</span> </span>
</div> </div>
</div> </article>
</div> </section>
<div class=" pagination"> <div class="pagination">
<button @click="loadDiaryEntries(currentPage - 1)" v-if="currentPage !== 1">{{ <button @click="loadDiaryEntries(currentPage - 1)" v-if="currentPage !== 1">{{
$t('socialnetwork.diary.prevPage') }}</button> $t('socialnetwork.diary.prevPage') }}</button>
<span>{{ $t('socialnetwork.diary.page') }} {{ currentPage }} / {{ totalPages }}</span> <span>{{ $t('socialnetwork.diary.page') }} {{ currentPage }} / {{ totalPages }}</span>
@@ -33,6 +40,7 @@
$t('socialnetwork.diary.nextPage') }}</button> $t('socialnetwork.diary.nextPage') }}</button>
</div> </div>
<ChooseDialog ref="chooseDialog" /> <ChooseDialog ref="chooseDialog" />
</div>
</template> </template>
<script> <script>
@@ -137,13 +145,38 @@ export default {
</script> </script>
<style scoped> <style scoped>
.diary-view {
max-width: 820px;
margin: 0 auto;
padding-bottom: 24px;
}
.diary-hero,
.new-entry-section { .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 { textarea {
width: 100%; height: 140px;
height: 100px;
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -152,13 +185,12 @@ textarea {
} }
.diary-entry { .diary-entry {
border-bottom: 1px solid #ccc;
margin-bottom: 1em; margin-bottom: 1em;
padding-bottom: 1em; padding: 18px 20px;
} }
.entry-info { .entry-info {
color: gray; color: var(--color-text-muted);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
@@ -176,13 +208,23 @@ textarea {
.pagination { .pagination {
margin-top: 1em; margin-top: 1em;
background-color: #7BBE55; color: var(--color-text-secondary);
color: #fff;
padding: 0.5em 0; padding: 0.5em 0;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
} }
.diary-entries { .diary-entries {
width: 400px; display: grid;
gap: 12px;
}
.diary-empty {
padding: 22px;
text-align: center;
color: var(--color-text-secondary);
} }
</style> </style>

View File

@@ -1,8 +1,16 @@
<template> <template>
<h2 class="link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2> <div class="forum-topic-view">
<h3 v-if="forumTopic">{{ forumTopic }}</h3> <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"> <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 v-html="sanitizedMessage(message)"></div>
<div class="footer"> <div class="footer">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)"> <span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
@@ -12,11 +20,13 @@
</div> </div>
</li> </li>
</ul> </ul>
</section>
<div class="editor-container"> <div class="editor-container surface-card">
<EditorContent v-if="editor" :editor="editor" class="editor" /> <EditorContent v-if="editor" :editor="editor" class="editor" />
</div> </div>
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button> <button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
</div>
</template> </template>
<script> <script>
@@ -98,6 +108,27 @@ export default {
} }
</script> </script>
<style lang="scss" scoped> <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 { .messages {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
@@ -105,14 +136,13 @@ export default {
} }
.messages > li { .messages > li {
border: 1px solid #7BBE55; margin-bottom: 0.75em;
margin-bottom: 0.25em; padding: 1rem 1.1rem;
padding: 0.5em;
} }
.messages > li > .footer { .messages > li > .footer {
color: #F9A22C; color: var(--color-text-muted);
font-size: 0.7em; font-size: 0.8em;
margin-top: 0.5em; margin-top: 0.5em;
display: flex; display: flex;
} }
@@ -127,10 +157,10 @@ export default {
.editor-container { .editor-container {
margin-top: 1rem; margin-top: 1rem;
border: 1px solid #ccc;
padding: 0; padding: 0;
min-height: 260px; min-height: 260px;
background-color: white; background-color: white;
overflow: hidden;
} }
.editor { .editor {
@@ -141,7 +171,7 @@ export default {
.editor :deep(.ProseMirror) { .editor :deep(.ProseMirror) {
min-height: 260px; min-height: 260px;
outline: none; outline: none;
padding: 10px; padding: 14px;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
} }

View File

@@ -1,5 +1,11 @@
<template> <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> <h2>{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
<p>Themen, Diskussionen und neue Beitraege an einem strukturierten Ort.</p>
</div>
<div class="creationtoggler"> <div class="creationtoggler">
<button @click="createNewTopic"> <button @click="createNewTopic">
{{ $t(!inCreation {{ $t(!inCreation
@@ -7,37 +13,43 @@
: 'socialnetwork.forum.hideNewTopic') }} : 'socialnetwork.forum.hideNewTopic') }}
</button> </button>
</div> </div>
</section>
<div v-if="inCreation"> <section v-if="inCreation" class="forum-creation surface-card">
<div>
<label class="newtitle"> <label class="newtitle">
{{ $t('socialnetwork.forum.topic') }} <span>{{ $t('socialnetwork.forum.topic') }}</span>
<input type="text" v-model="newTitle" /> <input type="text" v-model="newTitle" />
</label> </label>
</div>
<div class="editor-container"> <div class="editor-container">
<EditorContent v-if="editor" :editor="editor" class="editor" /> <EditorContent v-if="editor" :editor="editor" class="editor" />
</div> </div>
<button @click="saveNewTopic"> <button @click="saveNewTopic">
{{ $t('socialnetwork.forum.createNewTopic') }} {{ $t('socialnetwork.forum.createNewTopic') }}
</button> </button>
</div> </section>
<div v-else-if="titles.length > 0"> <section v-else-if="titles.length > 0" class="forum-topics surface-card">
<!-- hier kommt deine bestehende TABLE + PAGINATION hin --> <ul class="topic-list">
<table> <li v-for="topic in titles" :key="topic.id" class="topic-card">
<!-- Kopfzeile, Spalten etc. --> <button type="button" class="topic-card__main" @click="openTopic(topic.id)">
</table> <strong>{{ topic.title }}</strong>
<span class="topic-card__meta">
{{ topic.user?.username || topic.owner?.username || 'Community' }}
</span>
</button>
</li>
</ul>
<div class="pagination"> <div class="pagination">
<button @click="goToPage(page-1)" :disabled="page<=1"></button> <button @click="goToPage(page-1)" :disabled="page<=1"></button>
<span>{{ page }} / {{ totalPages }}</span> <span>{{ page }} / {{ totalPages }}</span>
<button @click="goToPage(page+1)" :disabled="page>=totalPages"></button> <button @click="goToPage(page+1)" :disabled="page>=totalPages"></button>
</div> </div>
</div> </section>
<div v-else> <div v-else class="forum-empty surface-card">
{{ $t('socialnetwork.forum.noTitles') }} {{ $t('socialnetwork.forum.noTitles') }}
</div> </div>
</div>
</template> </template>
<script> <script>
@@ -156,20 +168,60 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.creationtoggler { .forum-view {
margin-bottom: 1em; 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 { .newtitle {
display: flex; display: flex;
gap: 1em; flex-direction: column;
vertical-align: middle; gap: 0.6em;
} margin-bottom: 1rem;
.newtitle input {
flex: 1;
} }
.editor-container { .editor-container {
margin: 1em 0; margin: 1em 0;
border: 1px solid #ccc; border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 0; padding: 0;
min-height: 260px; min-height: 260px;
background-color: white; background-color: white;
@@ -189,16 +241,62 @@ export default {
.editor :deep(.ProseMirror p) { margin: 0 0 .6rem; } .editor :deep(.ProseMirror p) { margin: 0 0 .6rem; }
.editor :deep(.ProseMirror p:first-child) { margin-top: 0; } .editor :deep(.ProseMirror p:first-child) { margin-top: 0; }
.editor :deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); } .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 { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 0.5em; gap: 0.5em;
margin: 1em 0; margin: 1em 0;
} }
.pagination button {
padding: 0.5em 1em;
}
.pagination span { .pagination span {
padding: 0.5em; 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> </style>

View File

@@ -1,13 +1,29 @@
<template> <template>
<div class="friends-view">
<section class="friends-hero surface-card">
<div> <div>
<span class="friends-kicker">Community</span>
<h2>{{ $t('friends.title') }}</h2> <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="tabs-container">
<div class="tab" v-for="(tab, index) in tabs" :key="tab.name" :class="{ active: activeTab === index }" <div class="tab" v-for="(tab, index) in tabs" :key="tab.name" :class="{ active: activeTab === index }"
@click="selectTab(index)"> @click="selectTab(index)">
{{ $t(tab.label) }} {{ $t(tab.label) }}
</div> </div>
</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" <v-data-table :items="paginatedData(tab.data, tab.pagination.page)" :headers="headers"
:items-per-page="tab.pagination.itemsPerPage" class="elevation-1"> :items-per-page="tab.pagination.itemsPerPage" class="elevation-1">
<template v-slot:body="{ items }"> <template v-slot:body="{ items }">
@@ -167,25 +183,85 @@ export default {
</script> </script>
<style scoped> <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 { .tabs-container {
display: flex; display: flex;
border-bottom: 1px solid #999; gap: 8px;
padding: 5px 0; padding: 0 0 12px;
border-bottom: 0;
flex-wrap: wrap;
} }
.tab { .tab {
padding: 2px 4px; padding: 8px 14px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
background-color: #fff; background-color: rgba(255, 255, 255, 0.7);
color: #333; color: var(--color-text-secondary);
font-weight: bold; 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; transition: background-color 0.3s ease, color 0.3s ease;
} }
.tab:not(.active):hover { .tab:not(.active):hover {
background-color: #ddd; background-color: rgba(248, 162, 43, 0.12);
} }
.tab.active { .tab.active {
@@ -194,6 +270,10 @@ export default {
border-color: #F9A22C; border-color: #F9A22C;
} }
.friends-panel {
padding: 16px;
}
.font-color-gender-male { .font-color-gender-male {
color: #1E90FF; color: #1E90FF;
} }
@@ -205,4 +285,11 @@ export default {
.font-color-gender-nonbinary { .font-color-gender-nonbinary {
color: #DAA520; color: #DAA520;
} }
@media (max-width: 960px) {
.friends-hero {
flex-direction: column;
align-items: flex-start;
}
}
</style> </style>

View File

@@ -1,7 +1,14 @@
<template> <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> <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="gallery-view">
<div class="sidebar"> <div class="sidebar surface-card">
<h3>{{ $t('socialnetwork.gallery.folders') }}</h3> <h3>{{ $t('socialnetwork.gallery.folders') }}</h3>
<ul class="tree"> <ul class="tree">
<folder-item v-for="folder in [folders]" :key="folder.id" :folder="folder" <folder-item v-for="folder in [folders]" :key="folder.id" :folder="folder"
@@ -13,7 +20,7 @@
</div> </div>
<div class="content"> <div class="content">
<div class="upload-section"> <div class="upload-section surface-card">
<div class="upload-header" @click="toggleUploadSection"> <div class="upload-header" @click="toggleUploadSection">
<span> <span>
<i class="icon-upload-toggle">{{ isUploadVisible ? '&#9650;' : '&#9660;' }}</i> <i class="icon-upload-toggle">{{ isUploadVisible ? '&#9650;' : '&#9660;' }}</i>
@@ -63,9 +70,9 @@
</div> </div>
</div> </div>
<div class="image-list"> <div class="image-list surface-card">
<h3>{{ $t('socialnetwork.gallery.images') }}</h3> <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)"> <li v-for="image in images" :key="image.id" @click="openImageDialog(image)">
<img :src="image.url || image.placeholder" alt="Loading..." /> <img :src="image.url || image.placeholder" alt="Loading..." />
<p>{{ image.title }}</p> <p>{{ image.title }}</p>
@@ -75,6 +82,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script> <script>
@@ -265,35 +273,95 @@ export default {
</script> </script>
<style scoped> <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 { .gallery-view {
display: flex; display: flex;
gap: 18px;
} }
.sidebar { .sidebar {
width: 200px; width: 240px;
margin-right: 20px; margin-right: 0;
padding: 18px;
} }
.content { .content {
flex: 1; flex: 1;
min-width: 0;
} }
.upload-section { .upload-section {
margin-bottom: 20px; margin-bottom: 20px;
padding: 18px;
} }
.image-list { .image-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-wrap: wrap; flex-wrap: wrap;
padding: 18px;
} }
.image-list li { .image-grid {
margin: 4px; 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 { .icon-upload-toggle {
float: left;
cursor: pointer; cursor: pointer;
} }
@@ -302,51 +370,23 @@ export default {
width: auto; width: auto;
} }
.folder-item { .upload-header {
padding: 5px; display: flex;
cursor: pointer; align-items: center;
} gap: 10px;
.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;
} }
.tree { .tree {
padding: 0; padding: 0;
} }
@media (max-width: 960px) {
.gallery-view {
flex-direction: column;
}
.sidebar {
width: auto;
}
}
</style> </style>

View File

@@ -1,12 +1,18 @@
<template> <template>
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2> <div class="guestbook-view">
<section class="guestbook-hero surface-card">
<div> <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>
<div v-else class="guestbook-entries"> <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" <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> <p v-html="sanitizedContent(entry)"></p>
<div class="entry-info"> <div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span> <span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
@@ -14,7 +20,7 @@
<span @click="openProfile(entry.senderUsername)">{{ entry.sender }}</span> <span @click="openProfile(entry.senderUsername)">{{ entry.sender }}</span>
</span> </span>
</div> </div>
</div> </article>
</div> </div>
<div class="pagination"> <div class="pagination">
<button @click="loadGuestbookEntries(currentPage - 1)" v-if="currentPage !== 1">{{ <button @click="loadGuestbookEntries(currentPage - 1)" v-if="currentPage !== 1">{{
@@ -85,10 +91,72 @@ export default {
</script> </script>
<style lang="css" scoped> <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 { .pagination {
margin-top: 1em; margin-top: 1em;
background-color: #7BBE55; color: var(--color-text-secondary);
color: #fff;
padding: 0.5em 0; padding: 0.5em 0;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
} }
</style> </style>

View File

@@ -1,36 +1,52 @@
<template> <template>
<div class="search-view"> <div class="search-view">
<section class="search-hero surface-card">
<div>
<span class="search-kicker">Community-Suche</span>
<h2>{{ $t('socialnetwork.usersearch.title') }}</h2> <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"> <form @submit.prevent="performSearch">
<div class="form-grid">
<div class="form-group"> <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" <input type="text" id="username" v-model="searchCriteria.username"
:placeholder="$t('socialnetwork.usersearch.username')" /> :placeholder="$t('socialnetwork.usersearch.username')" />
</div> </div>
<div class="form-group"> <div class="form-group form-group--age">
<label for="ageFrom">{{ $t('socialnetwork.usersearch.age_from') }}:</label> <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" <input type="number" id="ageFrom" v-model="searchCriteria.ageFrom" :min="14" :max="150"
:placeholder="$t('socialnetwork.usersearch.age_from')" class="age-input" /> :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" <input type="number" id="ageTo" v-model="searchCriteria.ageTo" :min="14" :max="150"
:placeholder="$t('socialnetwork.usersearch.age_to')" class="age-input" /> :placeholder="$t('socialnetwork.usersearch.age_to')" class="age-input" />
</div> </div>
</div>
<div class="form-group"> <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" <multiselect v-model="searchCriteria.gender" :options="genderOptions" :multiple="true"
:close-on-select="false" :placeholder="$t('socialnetwork.usersearch.gender')" label="name" :close-on-select="false" :placeholder="$t('socialnetwork.usersearch.gender')" label="name"
track-by="name" /> track-by="name" />
</div> </div>
</div>
<div class="form-group"> <div class="form-actions">
<button type="submit" class="search-button">{{ $t('socialnetwork.usersearch.search_button') }}</button> <button type="submit" class="search-button">{{ $t('socialnetwork.usersearch.search_button') }}</button>
</div> </div>
</form> </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> <h3>{{ $t('socialnetwork.usersearch.results_title') }}</h3>
<span class="results-count">{{ searchResults.length }} Treffer</span>
</div>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -47,8 +63,8 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </section>
<div v-else class="no-results"> <div v-else class="no-results surface-card">
{{ $t('socialnetwork.usersearch.no_results') }} {{ $t('socialnetwork.usersearch.no_results') }}
</div> </div>
</div> </div>
@@ -114,83 +130,117 @@ export default {
<style scoped> <style scoped>
.search-view { .search-view {
max-width: 600px; max-width: var(--content-max-width);
margin: 0 auto; 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; 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 { .form-group {
display: flex; display: flex;
align-items: center; flex-direction: column;
margin-bottom: 10px; gap: 8px;
} }
label { label {
width: 120px; font-weight: 700;
font-weight: bold; color: var(--color-text-secondary);
margin-right: 10px;
text-align: right;
} }
input, .age-range {
.multiselect__input { display: flex;
flex: 1; align-items: center;
padding: 5px; gap: 8px;
border-radius: 4px;
border: 1px solid #ccc;
} }
.age-input { .age-input {
width: 70px; width: 100%;
margin-right: 10px;
} }
.search-results { .age-separator {
margin-top: 20px; color: var(--color-text-muted);
font-size: 0.88rem;
white-space: nowrap;
} }
.search-results ul { .form-actions {
list-style-type: none; margin-top: 14px;
padding: 0;
} }
.search-results li { .results-header {
padding: 8px; display: flex;
background: #f9f9f9; align-items: center;
border-bottom: 1px solid #ddd; justify-content: space-between;
margin-bottom: 12px;
}
.results-count {
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 700;
} }
table { table {
width: 100%;
margin: 0.5em 0; margin: 0.5em 0;
padding: 0; padding: 0;
border-collapse: collapse; border-collapse: collapse;
background: rgba(255, 255, 255, 0.62);
border-radius: var(--radius-lg);
overflow: hidden;
} }
thead { thead {
color: #7BBE55; color: #42634e;
} }
th, td { th, td {
padding-right: 1em; padding: 12px 14px;
} }
th, td:not:last-child { tbody tr + tr td {
border-bottom: 1px solid #7E471B; border-top: 1px solid var(--color-border);
} }
.clickable { .clickable {
cursor: pointer; cursor: pointer;
font-weight: 700;
} }
.no-results { .no-results {
margin-top: 20px;
text-align: center; text-align: center;
color: #888; color: var(--color-text-secondary);
} }
.g-male { .g-male {
@@ -200,4 +250,14 @@ th, td:not:last-child {
.g-female { .g-female {
color: #ff3377; color: #ff3377;
} }
@media (max-width: 960px) {
.form-grid {
grid-template-columns: 1fr;
}
.age-range {
flex-wrap: wrap;
}
}
</style> </style>

View File

@@ -1,26 +1,31 @@
<template> <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> <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-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div> <div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else> <div v-else>
<div v-show="!practiceOpen"> <div v-show="!practiceOpen">
<div class="row"> <div class="row row--actions">
<button @click="back">{{ $t('general.back') }}</button> <button @click="back">{{ $t('general.back') }}</button>
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button> <button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button> <button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div> </div>
<div class="row" v-if="chapter.isOwner"> <div class="editor-card" v-if="chapter.isOwner">
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3> <h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
<div class="grid"> <div class="grid">
<label> <label>
{{ $t('socialnetwork.vocab.learningWord') }} <span>{{ $t('socialnetwork.vocab.learningWord') }}</span>
<input v-model="learning" type="text" /> <input v-model="learning" type="text" />
</label> </label>
<label> <label>
{{ $t('socialnetwork.vocab.referenceWord') }} <span>{{ $t('socialnetwork.vocab.referenceWord') }}</span>
<input v-model="reference" type="text" /> <input v-model="reference" type="text" />
</label> </label>
</div> </div>
@@ -29,10 +34,9 @@
</button> </button>
</div> </div>
<hr /> <div v-if="vocabs.length === 0" class="empty-state">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
<div v-else class="table-wrap">
<div v-if="vocabs.length === 0">{{ $t('socialnetwork.vocab.noVocabs') }}</div> <table class="tbl">
<table v-else class="tbl">
<thead> <thead>
<tr> <tr>
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th> <th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
@@ -49,6 +53,8 @@
</div> </div>
</div> </div>
</div> </div>
</section>
</div>
<VocabPracticeDialog ref="practiceDialog" /> <VocabPracticeDialog ref="practiceDialog" />
<VocabSearchDialog ref="searchDialog" /> <VocabSearchDialog ref="searchDialog" />
@@ -147,30 +153,120 @@ export default {
</script> </script>
<style scoped> <style scoped>
.box { .vocab-chapter-view {
background: #f6f6f6; display: grid;
padding: 12px; gap: 18px;
border: 1px solid #ccc;
display: inline-block;
} }
.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 { .row {
margin-bottom: 10px; 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 { .grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 10px; gap: 10px;
margin-bottom: 8px; 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 { .tbl {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
background: rgba(255, 255, 255, 0.68);
border-radius: var(--radius-md);
overflow: hidden;
} }
.tbl th, .tbl th,
.tbl td { .tbl td {
border: 1px solid #ccc; border: 1px solid var(--color-border);
padding: 6px; 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> </style>

View File

@@ -1,8 +1,14 @@
<template> <template>
<div class="vocab-course-list"> <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> <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"> <div class="actions">
<button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button> <button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button>
<button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button> <button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button>
@@ -361,14 +367,37 @@ export default {
<style scoped> <style scoped>
.vocab-course-list { .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 { .box {
background: #f6f6f6; padding: 18px;
padding: 12px; border-radius: var(--radius-lg);
border: 1px solid #ccc;
border-radius: 4px;
} }
.actions { .actions {

Some files were not shown because too many files have changed in this diff Show More