Compare commits

35 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
Torsten Schulz (local)
a7688e4ed5 Revert "Refactor DirectorInfo and SaleSection components to unify speedLabel logic and remove unnecessary watch properties"
This reverts commit 8c40144734.
2026-02-09 15:56:48 +01:00
Torsten Schulz (local)
9c91d99bed Add Spanish locale files and initial translations 2026-02-09 15:55:04 +01:00
127 changed files with 13931 additions and 2207 deletions

View File

@@ -36,6 +36,19 @@ const app = express();
// - LOG_ALL_REQ=1: Logge alle Requests
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
const defaultCorsOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000',
'http://127.0.0.1:5173'
];
const corsOrigins = (process.env.CORS_ORIGINS || process.env.FRONTEND_URL || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const effectiveCorsOrigins = corsOrigins.length > 0 ? corsOrigins : defaultCorsOrigins;
const corsAllowAll = process.env.CORS_ALLOW_ALL === '1';
app.use((req, res, next) => {
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
req.reqId = reqId;
@@ -51,15 +64,26 @@ app.use((req, res, next) => {
});
const corsOptions = {
origin: ['http://localhost:3000', 'http://localhost:5173', 'http://127.0.0.1:3000', 'http://127.0.0.1:5173'],
origin(origin, callback) {
if (!origin) {
return callback(null, true);
}
if (corsAllowAll || effectiveCorsOrigins.includes(origin)) {
return callback(null, true);
}
return callback(null, false);
},
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'userId', 'authCode'],
allowedHeaders: ['Content-Type', 'Authorization', 'userid', 'authcode', 'userId', 'authCode'],
credentials: true,
preflightContinue: false,
optionsSuccessStatus: 204
};
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(express.json()); // To handle JSON request bodies
app.use('/api/chat', chatRouter);

View File

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

View File

@@ -12,25 +12,51 @@ const productionEnvPath = '/opt/yourpart/backend/.env';
const localEnvPath = path.resolve(__dirname, '../.env');
let envPath = localEnvPath; // Fallback
let usingProduction = false;
if (fs.existsSync(productionEnvPath)) {
envPath = productionEnvPath;
console.log('[env] Lade Produktions-.env:', productionEnvPath);
// Prüfe Lesbarkeit bevor wir versuchen, sie zu laden
try {
fs.accessSync(productionEnvPath, fs.constants.R_OK);
envPath = productionEnvPath;
usingProduction = true;
console.log('[env] Produktions-.env gefunden und lesbar:', productionEnvPath);
} catch (err) {
console.warn('[env] Produktions-.env vorhanden, aber nicht lesbar - verwende lokale .env stattdessen:', productionEnvPath);
console.warn('[env] Fehler:', err && err.message);
envPath = localEnvPath;
}
} else {
console.log('[env] Lade lokale .env:', localEnvPath);
console.log('[env] Produktions-.env nicht gefunden, lade lokale .env:', localEnvPath);
}
// Lade .env-Datei
// Lade .env-Datei (robust gegen Fehler)
console.log('[env] Versuche .env zu laden von:', envPath);
console.log('[env] Datei existiert:', fs.existsSync(envPath));
console.log('[env] Datei lesbar:', fs.accessSync ? (() => { try { fs.accessSync(envPath, fs.constants.R_OK); return true; } catch { return false; } })() : 'unbekannt');
const result = dotenv.config({ path: envPath });
if (result.error) {
console.warn('[env] Konnte .env nicht laden:', result.error.message);
console.warn('[env] Fehler-Details:', result.error);
} else {
console.log('[env] .env erfolgreich geladen von:', envPath);
console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
let result;
try {
result = dotenv.config({ path: envPath });
if (result.error) {
console.warn('[env] Konnte .env nicht laden:', result.error.message);
console.warn('[env] Fehler-Details:', result.error);
} else {
console.log('[env] .env erfolgreich geladen von:', envPath, usingProduction ? '(production)' : '(local)');
console.log('[env] Geladene Variablen:', Object.keys(result.parsed || {}));
}
} catch (err) {
// Sollte nicht passieren, aber falls dotenv intern eine Exception wirft (z.B. EACCES), fange sie ab
console.warn('[env] Unerwarteter Fehler beim Laden der .env:', err && err.message);
console.warn('[env] Stack:', err && err.stack);
if (envPath !== localEnvPath && fs.existsSync(localEnvPath)) {
console.log('[env] Versuche stattdessen lokale .env:', localEnvPath);
try {
result = dotenv.config({ path: localEnvPath });
if (!result.error) {
console.log('[env] Lokale .env erfolgreich geladen von:', localEnvPath);
}
} catch (err2) {
console.warn('[env] Konnte lokale .env auch nicht laden:', err2 && err2.message);
}
}
}
// Debug: Zeige Redis-Konfiguration

View File

@@ -13,6 +13,9 @@ class ChatController {
this.sendOneToOneMessage = this.sendOneToOneMessage.bind(this);
this.getOneToOneMessageHistory = this.getOneToOneMessageHistory.bind(this);
this.getRoomList = this.getRoomList.bind(this);
this.getRoomCreateOptions = this.getRoomCreateOptions.bind(this);
this.getOwnRooms = this.getOwnRooms.bind(this);
this.deleteOwnRoom = this.deleteOwnRoom.bind(this);
}
async getMessages(req, res) {
@@ -175,6 +178,41 @@ class ChatController {
res.status(500).json({ error: error.message });
}
}
async getRoomCreateOptions(req, res) {
try {
const options = await chatService.getRoomCreateOptions();
res.status(200).json(options);
} catch (error) {
res.status(500).json({ error: error.message });
}
}
async getOwnRooms(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const rooms = await chatService.getOwnRooms(hashedUserId);
res.status(200).json(rooms);
} catch (error) {
const status = error.message === 'user_not_found' ? 404 : 500;
res.status(status).json({ error: error.message });
}
}
async deleteOwnRoom(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const roomId = Number.parseInt(req.params.id, 10);
if (!Number.isInteger(roomId) || roomId <= 0) {
return res.status(400).json({ error: 'invalid_room_id' });
}
await chatService.deleteOwnRoom(hashedUserId, roomId);
res.status(204).send();
} catch (error) {
const status = error.message === 'room_not_found_or_not_owner' || error.message === 'user_not_found' ? 404 : 500;
res.status(status).json({ error: error.message });
}
}
}
export default ChatController;

View File

@@ -58,6 +58,10 @@ class FalukantController {
if (!page) page = 1;
return this.service.moneyHistory(userId, page, filter);
});
this.moneyHistoryGraph = this._wrapWithUser((userId, req) => {
const { range } = req.body || {};
return this.service.moneyHistoryGraph(userId, range || '24h');
});
this.getStorage = this._wrapWithUser((userId, req) => this.service.getStorage(userId, req.params.branchId));
this.buyStorage = this._wrapWithUser((userId, req) => {
const { branchId, amount, stockTypeId } = req.body;
@@ -94,6 +98,8 @@ class FalukantController {
if (!result) throw { status: 404, message: 'No family data found' };
return result;
});
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId));
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId));
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId));
this.cancelWooing = this._wrapWithUser(async (userId) => {
@@ -123,6 +129,9 @@ class FalukantController {
});
this.getTitlesOfNobility = this._wrapWithUser((userId) => this.service.getTitlesOfNobility(userId));
this.getReputationActions = this._wrapWithUser((userId) => this.service.getReputationActions(userId));
this.executeReputationAction = this._wrapWithUser((userId, req) =>
this.service.executeReputationAction(userId, req.body?.actionTypeId), { successStatus: 201 });
this.getHouseTypes = this._wrapWithUser((userId) => this.service.getHouseTypes(userId));
this.getMoodAffect = this._wrapWithUser((userId) => this.service.getMoodAffect(userId));
this.getCharacterAffect = this._wrapWithUser((userId) => this.service.getCharacterAffect(userId));
@@ -142,6 +151,17 @@ class FalukantController {
const { characterId: childId, firstName } = req.body;
return this.service.baptise(userId, childId, firstName);
});
this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId));
this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId));
this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId));
this.applyForChurchPosition = this._wrapWithUser((userId, req) => {
const { officeTypeId, regionId } = req.body;
return this.service.applyForChurchPosition(userId, officeTypeId, regionId);
});
this.decideOnChurchApplication = this._wrapWithUser((userId, req) => {
const { applicationId, decision } = req.body;
return this.service.decideOnChurchApplication(userId, applicationId, decision);
});
this.getEducation = this._wrapWithUser((userId) => this.service.getEducation(userId));
this.sendToSchool = this._wrapWithUser((userId, req) => {
@@ -154,25 +174,20 @@ class FalukantController {
this.takeBankCredits = this._wrapWithUser((userId, req) => this.service.takeBankCredits(userId, req.body.height));
this.getNobility = this._wrapWithUser((userId) => this.service.getNobility(userId));
this.advanceNobility = this._wrapWithUser(async (userId) => {
this.advanceNobility = this._wrapWithUser((userId) => this.service.advanceNobility(userId));
this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId));
this.healthActivity = this._wrapWithUser(async (userId, req) => {
try {
return await this.service.advanceNobility(userId);
return await this.service.healthActivity(userId, req.body.measureTr);
} catch (e) {
if (e && e.name === 'PreconditionError') {
if (e.message === 'nobilityTooSoon') {
throw { status: 412, message: 'nobilityTooSoon', retryAt: e.meta?.retryAt };
}
if (e.message === 'nobilityRequirements') {
throw { status: 412, message: 'nobilityRequirements', unmet: e.meta?.unmet || [] };
}
if (e && e.name === 'PreconditionError' && e.message === 'tooClose') {
throw { status: 412, message: 'tooClose', retryAt: e.meta?.retryAt };
}
throw e;
}
});
this.getHealth = this._wrapWithUser((userId) => this.service.getHealth(userId));
this.healthActivity = this._wrapWithUser((userId, req) => this.service.healthActivity(userId, req.body.measureTr));
this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId));
this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId));
this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId));
@@ -189,6 +204,13 @@ class FalukantController {
}
return this.service.getProductPriceInRegion(userId, productId, regionId);
});
this.getAllProductPricesInRegion = this._wrapWithUser((userId, req) => {
const regionId = parseInt(req.query.regionId, 10);
if (Number.isNaN(regionId)) {
throw new Error('regionId is required');
}
return this.service.getAllProductPricesInRegion(userId, regionId);
});
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
const productId = parseInt(req.query.productId, 10);
const currentPrice = parseFloat(req.query.currentPrice);
@@ -198,6 +220,16 @@ class FalukantController {
}
return this.service.getProductPricesInCities(userId, productId, currentPrice, currentRegionId);
});
this.getProductPricesInCitiesBatch = this._wrapWithUser((userId, req) => {
const body = req.body || {};
const items = Array.isArray(body.items) ? body.items : [];
const currentRegionId = body.currentRegionId != null ? parseInt(body.currentRegionId, 10) : null;
const valid = items.map(i => ({
productId: parseInt(i.productId, 10),
currentPrice: parseFloat(i.currentPrice)
})).filter(i => !Number.isNaN(i.productId) && !Number.isNaN(i.currentPrice));
return this.service.getProductPricesInCitiesBatch(userId, valid, Number.isNaN(currentRegionId) ? null : currentRegionId);
});
this.renovate = this._wrapWithUser((userId, req) => this.service.renovate(userId, req.body.element));
this.renovateAll = this._wrapWithUser((userId) => this.service.renovateAll(userId));
@@ -205,6 +237,7 @@ class FalukantController {
this.getNotifications = this._wrapWithUser((userId) => this.service.getNotifications(userId));
this.getAllNotifications = this._wrapWithUser((userId, req) => this.service.getAllNotifications(userId, req.query.page, req.query.size));
this.markNotificationsShown = this._wrapWithUser((userId) => this.service.markNotificationsShown(userId), { successStatus: 202 });
this.getDashboardWidget = this._wrapWithUser((userId) => this.service.getDashboardWidget(userId));
this.getUndergroundTargets = this._wrapWithUser((userId) => this.service.getPoliticalOfficeHolders(userId));
this.searchUsers = this._wrapWithUser((userId, req) => {
@@ -279,7 +312,13 @@ class FalukantController {
} catch (error) {
console.error('Controller error:', error);
const status = error.status && typeof error.status === 'number' ? error.status : 500;
res.status(status).json({ error: error.message || 'Internal error' });
// Wenn error ein Objekt mit status ist, alle Felder außer status übernehmen
if (error && typeof error === 'object' && error.status && typeof error.status === 'number') {
const { status: errorStatus, ...errorData } = error;
res.status(errorStatus).json({ error: error.message || errorData.message || 'Internal error', ...errorData });
} else {
res.status(status).json({ error: error.message || 'Internal error' });
}
}
};
}

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,17 +15,7 @@ ProductType.init({
allowNull: false},
sellCost: {
type: DataTypes.INTEGER,
allowNull: false}
,
sellCostMinNeutral: {
type: DataTypes.DECIMAL,
allowNull: true,
field: 'sell_cost_min_neutral'
},
sellCostMaxNeutral: {
type: DataTypes.DECIMAL,
allowNull: true,
field: 'sell_cost_max_neutral'
allowNull: false
}
}, {
sequelize,

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.get('/oneToOne/messageHistory', authenticate, chatController.getOneToOneMessageHistory); // Neue Route zum Abrufen der Nachrichtengeschichte
router.get('/rooms', chatController.getRoomList);
router.get('/room-create-options', authenticate, chatController.getRoomCreateOptions);
router.get('/my-rooms', authenticate, chatController.getOwnRooms);
router.delete('/my-rooms/:id', authenticate, chatController.deleteOwnRoom);
export default router;

View File

@@ -11,6 +11,7 @@ router.get('/character/affect', falukantController.getCharacterAffect);
router.get('/name/randomfirstname/:gender', falukantController.randomFirstName);
router.get('/name/randomlastname', falukantController.randomLastName);
router.get('/info', falukantController.getInfo);
router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.get('/branches/types', falukantController.getBranchTypes);
router.get('/branches/:branch', falukantController.getBranch);
router.get('/branches', falukantController.getBranches);
@@ -28,6 +29,7 @@ router.get('/inventory/?:branchId', falukantController.getInventory);
router.post('/sell/all', falukantController.sellAllProducts);
router.post('/sell', falukantController.sellProduct);
router.post('/moneyhistory', falukantController.moneyHistory);
router.post('/moneyhistory/graph', falukantController.moneyHistoryGraph);
router.get('/storage/:branchId', falukantController.getStorage);
router.post('/storage', falukantController.buyStorage);
router.delete('/storage', falukantController.sellStorage);
@@ -45,11 +47,15 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/cancel-wooing', falukantController.cancelWooing);
router.post('/family/set-heir', falukantController.setHeir);
router.get('/heirs/potential', falukantController.getPotentialHeirs);
router.post('/heirs/select', falukantController.selectHeir);
router.get('/family/gifts', falukantController.getGifts);
router.get('/family/children', falukantController.getChildren);
router.post('/family/gift', falukantController.sendGift);
router.get('/family', falukantController.getFamily);
router.get('/nobility/titels', falukantController.getTitlesOfNobility);
router.get('/reputation/actions', falukantController.getReputationActions);
router.post('/reputation/actions', falukantController.executeReputationAction);
router.get('/houses/types', falukantController.getHouseTypes);
router.get('/houses/buyable', falukantController.getBuyableHouses);
router.get('/houses', falukantController.getUserHouse);
@@ -61,6 +67,11 @@ router.post('/party', falukantController.createParty);
router.get('/party', falukantController.getParties);
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
router.post('/church/baptise', falukantController.baptise);
router.get('/church/overview', falukantController.getChurchOverview);
router.get('/church/positions/available', falukantController.getAvailableChurchPositions);
router.get('/church/applications/supervised', falukantController.getSupervisedApplications);
router.post('/church/positions/apply', falukantController.applyForChurchPosition);
router.post('/church/applications/decide', falukantController.decideOnChurchApplication);
router.get('/education', falukantController.getEducation);
router.post('/education', falukantController.sendToSchool);
router.get('/bank/overview', falukantController.getBankOverview);
@@ -72,13 +83,14 @@ router.get('/health', falukantController.getHealth);
router.post('/health', falukantController.healthActivity);
router.get('/politics/overview', falukantController.getPoliticsOverview);
router.get('/politics/open', falukantController.getOpenPolitics);
router.post('/politics/open', falukantController.applyForElections);
router.get('/politics/elections', falukantController.getElections);
router.post('/politics/elections', falukantController.vote);
router.get('/politics/open', falukantController.getOpenPolitics);
router.post('/politics/open', falukantController.applyForElections);
router.get('/cities', falukantController.getRegions);
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
router.get('/products/prices-in-region', falukantController.getAllProductPricesInRegion);
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
router.post('/products/prices-in-cities-batch', falukantController.getProductPricesInCitiesBatch);
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
router.get('/vehicles/types', falukantController.getVehicleTypes);
router.post('/vehicles', falukantController.buyVehicles);

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
'Zeitformen - Grundlagen': [
{
@@ -743,6 +926,122 @@ const BISAYA_EXERCISES = {
}
],
// Lektion 13: Alltagsgespräche - Teil 2
'Alltagsgespräche - Teil 2': [
{
exerciseTypeId: 2, // multiple_choice
title: 'Wie sagt man "Wohin gehst du?"?',
instruction: 'Wähle die richtige Frage aus.',
questionData: {
type: 'multiple_choice',
question: 'Wie sagt man "Wohin gehst du?" auf Bisaya?',
options: ['Asa ka padulong?', 'Kumusta ka?', 'Unsa imong ginabuhat?', 'Tagpila ni?']
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Asa ka padulong?" bedeutet "Wohin gehst du?" - "Asa" = wo/wohin, "padulong" = unterwegs nach.'
},
{
exerciseTypeId: 2, // multiple_choice
title: 'Wie sagt man "Was ist dein Plan?"?',
instruction: 'Wähle die richtige Frage aus.',
questionData: {
type: 'multiple_choice',
question: 'Wie sagt man "Was ist dein Plan?" auf Bisaya?',
options: ['Unsa imong plano?', 'Asa ka padulong?', 'Unsa imong ngalan?', 'Kinsa ka?']
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Unsa imong plano?" bedeutet "Was ist dein Plan?" - "plano" = Plan.'
},
{
exerciseTypeId: 2, // multiple_choice
title: 'Was bedeutet "Moadto ko sa balay"?',
instruction: 'Wähle die richtige Übersetzung.',
questionData: {
type: 'multiple_choice',
question: 'Was bedeutet "Moadto ko sa balay"?',
options: ['Ich gehe nach Hause', 'Ich gehe zur Arbeit', 'Ich gehe in die Schule', 'Ich gehe in die Kirche']
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Moadto ko sa balay" bedeutet "Ich gehe nach Hause" - "balay" = Haus.'
},
{
exerciseTypeId: 2, // multiple_choice
title: 'Was bedeutet "Moadto ko sa merkado"?',
instruction: 'Wähle die richtige Übersetzung.',
questionData: {
type: 'multiple_choice',
question: 'Was bedeutet "Moadto ko sa merkado"?',
options: ['Ich gehe zum Markt', 'Ich gehe nach Hause', 'Ich gehe in die Kirche', 'Ich gehe ins Krankenhaus']
},
answerData: { type: 'multiple_choice', correctAnswer: 0 },
explanation: '"Moadto ko sa merkado" bedeutet "Ich gehe zum Markt" - "merkado" = Markt.'
},
{
exerciseTypeId: 1, // gap_fill
title: 'Fragen zum Weg vervollständigen',
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
questionData: {
type: 'gap_fill',
text: '{gap} ka padulong? (Wohin gehst du?) | {gap} imong plano? (Was ist dein Plan?)',
gaps: 2
},
answerData: {
type: 'gap_fill',
answers: ['Asa', 'Unsa']
},
explanation: '"Asa" = wo/wohin, "Unsa" = was.'
},
{
exerciseTypeId: 1, // gap_fill
title: 'Antworten auf Weg-Fragen',
instruction: 'Fülle die Lücken mit den richtigen Bisaya-Wörtern.',
questionData: {
type: 'gap_fill',
text: 'Moadto ko sa {gap}. (Ich gehe nach Hause.) | Moadto ko sa {gap}. (Ich gehe zum Markt.)',
gaps: 2
},
answerData: {
type: 'gap_fill',
answers: ['balay', 'merkado']
},
explanation: '"balay" = Haus, "merkado" = Markt.'
},
{
exerciseTypeId: 4, // transformation
title: 'Weg-Satz übersetzen 1',
instruction: 'Übersetze den Satz ins Bisaya.',
questionData: {
type: 'transformation',
text: 'Ich gehe nach Hause',
sourceLanguage: 'Deutsch',
targetLanguage: 'Bisaya'
},
answerData: {
type: 'transformation',
correct: 'Moadto ko sa balay',
alternatives: ['Mo-uli ko', 'Moadto ko sa among balay']
},
explanation: '"Moadto ko sa balay" und "Mo-uli ko" können beide "Ich gehe nach Hause" bedeuten.'
},
{
exerciseTypeId: 4, // transformation
title: 'Weg-Satz übersetzen 2',
instruction: 'Übersetze den Satz ins Bisaya.',
questionData: {
type: 'transformation',
text: 'Ich gehe in die Kirche',
sourceLanguage: 'Deutsch',
targetLanguage: 'Bisaya'
},
answerData: {
type: 'transformation',
correct: 'Moadto ko sa simbahan',
alternatives: ['Adto ko sa simbahan', 'Moadto ko sa among simbahan']
},
explanation: '"simbahan" = Kirche.'
}
],
// Woche 1 - Wiederholung (Lektion 9)
'Woche 1 - Wiederholung': [
{
@@ -971,7 +1270,10 @@ async function createBisayaCourseContent() {
const replacePlaceholders = [
'Woche 1 - Wiederholung',
'Woche 1 - Vokabeltest',
'Alltagsgespräche - Teil 1'
'Alltagsgespräche - Teil 1',
'Alltagsgespräche - Teil 2',
'Haus & Familie',
'Ort & Richtung'
].includes(lesson.title);
const existingCount = await VocabGrammarExercise.count({
where: { lessonId: lesson.id }

View File

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

View File

@@ -1,7 +1,9 @@
import { v4 as uuidv4 } from 'uuid';
import amqp from 'amqplib/callback_api.js';
import User from '../models/community/user.js';
import Room from '../models/chat/room.js';
const RABBITMQ_URL = 'amqp://localhost';
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'oneToOne_messages';
class ChatService {
@@ -11,11 +13,37 @@ class ChatService {
this.users = [];
this.randomChats = [];
this.oneToOneChats = [];
this.channel = null;
this.amqpAvailable = false;
this.initRabbitMq();
}
initRabbitMq() {
amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) throw err;
connection.createChannel((err, channel) => {
if (err) throw err;
if (err) {
console.warn(`[chatService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - fallback ohne Queue wird verwendet.`);
return;
}
connection.on('error', (connectionError) => {
console.warn('[chatService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
this.channel = null;
this.amqpAvailable = false;
});
connection.on('close', () => {
console.warn('[chatService] RabbitMQ-Verbindung geschlossen.');
this.channel = null;
this.amqpAvailable = false;
});
connection.createChannel((channelError, channel) => {
if (channelError) {
console.warn('[chatService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
return;
}
this.channel = channel;
this.amqpAvailable = true;
channel.assertQueue(QUEUE, { durable: false });
});
});
@@ -116,8 +144,14 @@ class ChatService {
history: [messageBundle],
});
}
if (this.channel) {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
if (this.channel && this.amqpAvailable) {
try {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
} catch (error) {
console.warn('[chatService] sendToQueue fehlgeschlagen, Queue-Bridge vorübergehend deaktiviert:', error.message);
this.channel = null;
this.amqpAvailable = false;
}
}
}
@@ -148,6 +182,66 @@ class ChatService {
]
});
}
async getRoomCreateOptions() {
const { default: UserRightType } = await import('../models/type/user_right.js');
const { default: InterestType } = await import('../models/type/interest.js');
const [rights, interests] = await Promise.all([
UserRightType.findAll({
attributes: ['id', 'title'],
order: [['id', 'ASC']]
}),
InterestType.findAll({
attributes: ['id', 'name'],
order: [['id', 'ASC']]
})
]);
return {
rights: rights.map((r) => ({ id: r.id, title: r.title })),
roomTypes: interests.map((i) => ({ id: i.id, name: i.name }))
};
}
async getOwnRooms(hashedUserId) {
const user = await User.findOne({
where: { hashedId: hashedUserId },
attributes: ['id']
});
if (!user) {
throw new Error('user_not_found');
}
return Room.findAll({
where: { ownerId: user.id },
attributes: ['id', 'title', 'isPublic', 'roomTypeId', 'ownerId'],
order: [['title', 'ASC']]
});
}
async deleteOwnRoom(hashedUserId, roomId) {
const user = await User.findOne({
where: { hashedId: hashedUserId },
attributes: ['id']
});
if (!user) {
throw new Error('user_not_found');
}
const deleted = await Room.destroy({
where: {
id: roomId,
ownerId: user.id
}
});
if (!deleted) {
throw new Error('room_not_found_or_not_owner');
}
return true;
}
}
export default new ChatService();

View File

@@ -2,7 +2,10 @@ import net from 'net';
import fs from 'fs';
import path from 'path';
const DEFAULT_CONFIG = { host: 'localhost', port: 1235 };
const DEFAULT_CONFIG = {
host: process.env.CHAT_TCP_HOST || 'localhost',
port: Number.parseInt(process.env.CHAT_TCP_PORT || '1235', 10),
};
function loadBridgeConfig() {
try {

File diff suppressed because it is too large Load Diff

View File

@@ -2,38 +2,110 @@
import { Server } from 'socket.io';
import amqp from 'amqplib/callback_api.js';
const RABBITMQ_URL = 'amqp://localhost';
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'chat_messages';
const MAX_PENDING_MESSAGES = 500;
function routeMessage(io, message) {
if (!message || typeof message !== 'object') return;
if (message.socketId) {
io.to(message.socketId).emit('newMessage', message);
return;
}
if (message.recipientSocketId) {
io.to(message.recipientSocketId).emit('newMessage', message);
return;
}
if (message.roomId) {
io.to(String(message.roomId)).emit('newMessage', message);
return;
}
if (message.room) {
io.to(String(message.room)).emit('newMessage', message);
return;
}
io.emit('newMessage', message);
}
export function setupWebSocket(server) {
const io = new Server(server);
let channel = null;
let pendingMessages = [];
const flushPendingMessages = () => {
if (!channel || pendingMessages.length === 0) return;
const queued = pendingMessages;
pendingMessages = [];
for (const message of queued) {
try {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
} catch (err) {
console.warn('[webSocketService] Flush fehlgeschlagen, Nachricht bleibt im Fallback:', err.message);
pendingMessages.unshift(message);
break;
}
}
};
amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) throw err;
if (err) {
console.warn(`[webSocketService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - WebSocket läuft ohne Queue-Bridge.`);
return;
}
connection.createChannel((err, channel) => {
if (err) throw err;
connection.on('error', (connectionError) => {
console.warn('[webSocketService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
channel = null;
});
connection.on('close', () => {
console.warn('[webSocketService] RabbitMQ-Verbindung geschlossen.');
channel = null;
});
connection.createChannel((channelError, createdChannel) => {
if (channelError) {
console.warn('[webSocketService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
return;
}
channel = createdChannel;
channel.assertQueue(QUEUE, { durable: false });
channel.consume(QUEUE, (msg) => {
if (!msg) return;
const message = JSON.parse(msg.content.toString());
routeMessage(io, message);
}, { noAck: true });
flushPendingMessages();
});
});
io.on('connection', (socket) => {
console.log('Client connected via WebSocket');
io.on('connection', (socket) => {
console.log('Client connected via WebSocket');
// Konsumiert Nachrichten aus RabbitMQ und sendet sie an den WebSocket-Client
channel.consume(QUEUE, (msg) => {
const message = JSON.parse(msg.content.toString());
io.emit('newMessage', message); // Broadcast an alle Clients
}, { noAck: true });
// Empfangt eine Nachricht vom WebSocket-Client und sendet sie an die RabbitMQ-Warteschlange
socket.on('newMessage', (message) => {
socket.on('newMessage', (message) => {
if (channel) {
try {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
});
} catch (err) {
console.warn('[webSocketService] sendToQueue fehlgeschlagen, nutze In-Memory-Fallback:', err.message);
channel = null;
}
}
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
if (!channel) {
pendingMessages.push(message);
if (pendingMessages.length > MAX_PENDING_MESSAGES) {
pendingMessages = pendingMessages.slice(-MAX_PENDING_MESSAGES);
}
return;
}
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
}

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

230
docs/UI_REDESIGN_PLAN.md Normal file
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_DAEMON_SOCKET=ws://localhost:4551
VITE_CHAT_WS_URL=ws://localhost.de:1235
VITE_DAEMON_SOCKET=ws://127.0.0.1:4551
VITE_CHAT_WS_URL=ws://127.0.0.1:1235

View File

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

View File

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

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="keywords" content="YourPart, Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer, Sprachen lernen, Falukant, Aufbauspiel, Minispiele, Match3, Taxi, Bildergalerie, Tagebuch, Freundschaften" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://www.your-part.de/" />
<link rel="canonical" href="%VITE_PUBLIC_BASE_URL%/" />
<meta name="author" content="YourPart" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="YourPart" />
<meta property="og:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
<meta property="og:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." />
<meta property="og:url" content="https://www.your-part.de/" />
<meta property="og:url" content="%VITE_PUBLIC_BASE_URL%/" />
<meta property="og:locale" content="de_DE" />
<meta property="og:image" content="%VITE_PUBLIC_BASE_URL%/images/logos/logo.png" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
<meta name="twitter:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." />
<meta name="twitter:image" content="%VITE_PUBLIC_BASE_URL%/images/logos/logo.png" />
<meta name="theme-color" content="#FF8C5A" />
<link rel="alternate" hreflang="de" href="%VITE_PUBLIC_BASE_URL%/" />
<link rel="alternate" hreflang="x-default" href="%VITE_PUBLIC_BASE_URL%/" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "YourPart",
"url": "https://www.your-part.de/",
"inLanguage": "de",
"description": "Community-Plattform mit Chat, Forum, Vokabeltrainer, Aufbauspiel Falukant und Minispielen",
"potentialAction": {
"@type": "SearchAction",
"target": "https://www.your-part.de/?q={search_term_string}",
"query-input": "required name=search_term_string"
},
"about": [
{
"@type": "SoftwareApplication",
"name": "Vokabeltrainer",
"description": "Lerne Sprachen mit dem interaktiven Vokabeltrainer",
"applicationCategory": "EducationalApplication",
"url": "https://www.your-part.de/socialnetwork/vocab"
},
{
"@type": "VideoGame",
"name": "Falukant",
"description": "Mittelalterliches Aufbauspiel mit Handel, Politik und Charakterentwicklung",
"gamePlatform": "Web",
"url": "https://www.your-part.de/falukant"
},
{
"@type": "VideoGame",
"name": "Match3",
"description": "Klassisches Match-3 Puzzle-Spiel",
"gamePlatform": "Web",
"url": "https://www.your-part.de/minigames/match3"
},
{
"@type": "VideoGame",
"name": "Taxi",
"description": "Taxi-Fahrspiel mit Passagieren und Strecken",
"gamePlatform": "Web",
"url": "https://www.your-part.de/minigames/taxi"
}
]
}
</script>
</head>
<body>
<div id="app"></div>
<noscript>
<section style="max-width:960px;margin:40px auto;padding:0 20px;font-family:Arial,sans-serif;line-height:1.6;">
<h1>YourPart</h1>
<p>YourPart ist eine Plattform fuer Community, Chat, Forum, Blogs, Vokabeltrainer, das Browser-Aufbauspiel Falukant und Minispiele.</p>
<p>Wichtige Bereiche: <a href="/blogs">Blogs</a>, <a href="/vokabeltrainer">Vokabeltrainer</a>, <a href="/falukant">Falukant</a> und <a href="/minigames">Minispiele</a>.</p>
</section>
</noscript>
<script type="module" src="/src/main.js"></script>
</body>

View File

@@ -1,6 +1,34 @@
User-agent: *
Allow: /
Disallow: /activate
Disallow: /admin/
Disallow: /friends
Disallow: /personal/
Disallow: /settings/
Disallow: /socialnetwork/diary
Disallow: /socialnetwork/forum/
Disallow: /socialnetwork/forumtopic/
Disallow: /socialnetwork/gallery
Disallow: /socialnetwork/guestbook
Disallow: /socialnetwork/search
Disallow: /socialnetwork/vocab/
Disallow: /falukant/home
Disallow: /falukant/create
Disallow: /falukant/branch/
Disallow: /falukant/moneyhistory
Disallow: /falukant/family
Disallow: /falukant/house
Disallow: /falukant/nobility
Disallow: /falukant/reputation
Disallow: /falukant/church
Disallow: /falukant/education
Disallow: /falukant/bank
Disallow: /falukant/directors
Disallow: /falukant/health
Disallow: /falukant/politics
Disallow: /falukant/darknet
Disallow: /minigames/match3
Disallow: /minigames/taxi
Sitemap: https://www.your-part.de/sitemap.xml

View File

@@ -8,6 +8,11 @@
<url>
<loc>https://www.your-part.de/blogs</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://www.your-part.de/vokabeltrainer</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
@@ -16,25 +21,8 @@
<priority>0.8</priority>
</url>
<url>
<loc>https://www.your-part.de/socialnetwork/vocab</loc>
<loc>https://www.your-part.de/minigames</loc>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://www.your-part.de/minigames</loc>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://www.your-part.de/minigames/match3</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://www.your-part.de/minigames/taxi</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

View File

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

View File

@@ -4,3 +4,17 @@ export const fetchPublicRooms = async () => {
const response = await apiClient.get("/api/chat/rooms");
return response.data; // expecting array of { id, title, ... }
};
export const fetchRoomCreateOptions = async () => {
const response = await apiClient.get("/api/chat/room-create-options");
return response.data;
};
export const fetchOwnRooms = async () => {
const response = await apiClient.get("/api/chat/my-rooms");
return response.data;
};
export const deleteOwnRoom = async (roomId) => {
await apiClient.delete(`/api/chat/my-rooms/${roomId}`);
};

View File

@@ -1,50 +1,355 @@
:root {
/* Moderne Farbpalette für bessere Lesbarkeit */
--color-primary-orange: #FFB84D; /* Gelbliches, sanftes Orange */
--color-primary-orange-hover: #FFC966; /* Noch helleres gelbliches Orange für Hover */
--color-primary-orange-light: #FFF8E1; /* Sehr helles gelbliches Orange für Hover-Hintergründe */
--color-primary-green: #4ADE80; /* Helles, freundliches Grün - passt zum Orange */
--color-primary-green-hover: #6EE7B7; /* Noch helleres Grün für Hover */
--color-text-primary: #1F2937; /* Dunkles Grau für Haupttext (bessere Lesbarkeit) */
--color-text-secondary: #5D4037; /* Dunkles Braun für sekundären Text/Hover */
--color-text-on-orange: #000000; /* Schwarz auf Orange */
--color-text-on-green: #000000; /* Schwarz auf Grün */
color-scheme: light;
--font-display: "Trebuchet MS", "Segoe UI", sans-serif;
--font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--color-bg: #f4f1ea;
--color-bg-elevated: #faf7f1;
--color-bg-muted: #f5eee2;
--color-surface: rgba(255, 251, 246, 0.94);
--color-surface-strong: #fffdfa;
--color-surface-accent: #fff4e5;
--color-border: rgba(93, 64, 55, 0.12);
--color-border-strong: rgba(93, 64, 55, 0.24);
--color-text-primary: #211910;
--color-text-secondary: #5f4b39;
--color-text-muted: #7a6857;
--color-text-on-accent: #fffaf4;
--color-primary: #f8a22b;
--color-primary-hover: #ea961f;
--color-primary-soft: rgba(248, 162, 43, 0.14);
--color-secondary: #78c38a;
--color-secondary-soft: rgba(120, 195, 138, 0.18);
--color-highlight: #ffcf74;
--color-success: #287d5a;
--color-warning: #c9821f;
--color-danger: #b13b35;
--shell-max-width: 1440px;
--content-max-width: 1200px;
--header-height: 62px;
--nav-height: 52px;
--footer-height: 46px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--radius-sm: 5px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 999px;
--shadow-soft: 0 12px 30px rgba(47, 29, 14, 0.08);
--shadow-medium: 0 20px 50px rgba(47, 29, 14, 0.12);
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.7);
--transition-fast: 140ms ease;
--transition-base: 220ms ease;
--color-primary-orange: var(--color-primary);
--color-primary-orange-hover: var(--color-primary-hover);
--color-primary-orange-light: #f9ece1;
--color-primary-green: #84c6a3;
--color-primary-green-hover: #95d1b0;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
body,
#app {
height: 100%;
}
html {
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.85), transparent 30%),
linear-gradient(180deg, #f8f2e8 0%, #f3ebdd 100%);
}
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f4f4f4;
font-family: var(--font-body);
color: var(--color-text-primary);
background: transparent;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
}
a {
text-decoration: none;
color: inherit;
text-decoration: none;
transition: color var(--transition-fast);
}
button {
margin-left: 10px;
padding: 5px 12px;
cursor: pointer;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
border: 1px solid var(--color-primary-orange);
border-radius: 4px;
transition: background 0.05s;
a:hover {
color: var(--color-primary);
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.button,
span.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 44px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: #2b1f14;
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.2);
cursor: pointer;
transition:
transform var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast);
}
button:hover {
background: var(--color-primary-orange-light);
button:hover,
.button:hover,
span.button:hover {
transform: translateY(-1px);
background: var(--color-primary-hover);
box-shadow: 0 10px 18px rgba(248, 162, 43, 0.24);
}
button:active,
.button:active,
span.button:active {
transform: translateY(0);
}
button:disabled,
.button:disabled,
span.button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a:focus-visible,
[role="button"]:focus-visible,
[role="menuitem"]:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(120, 195, 138, 0.32);
outline-offset: 2px;
}
input:not([type="checkbox"]):not([type="radio"]),
select,
textarea {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.9);
color: var(--color-text-primary);
box-shadow: var(--shadow-inset);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast);
}
textarea {
min-height: 120px;
padding: 14px;
resize: vertical;
}
input:not([type="checkbox"]):not([type="radio"]):hover,
select:hover,
textarea:hover {
border-color: var(--color-border-strong);
}
input:not([type="checkbox"]):not([type="radio"]):focus,
select:focus,
textarea:focus {
border-color: rgba(120, 195, 138, 0.65);
box-shadow: 0 0 0 4px rgba(120, 195, 138, 0.16);
}
input[type="checkbox"],
input[type="radio"] {
width: auto;
min-height: 0;
padding: 0;
margin: 0;
border: 0;
box-shadow: none;
accent-color: var(--color-primary);
}
input[type="checkbox"] {
inline-size: 16px;
block-size: 16px;
}
input[type="radio"] {
inline-size: 16px;
block-size: 16px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0 0 var(--space-3);
font-family: var(--font-display);
line-height: 1.08;
letter-spacing: -0.02em;
}
h1 {
font-size: clamp(2rem, 3.4vw, 3.6rem);
}
h2 {
font-size: clamp(1.5rem, 2vw, 2.4rem);
}
h3 {
font-size: clamp(1.15rem, 1.5vw, 1.5rem);
}
p,
ul,
ol {
margin: 0 0 var(--space-4);
}
ul,
ol {
padding-left: 1.25rem;
}
img {
max-width: 100%;
display: block;
}
main,
.contenthidden {
width: 100%;
height: 100%;
overflow: hidden;
}
.contentscroll {
width: 100%;
height: 100%;
overflow: auto;
}
.app-content__inner > .contenthidden {
height: auto;
overflow: visible;
}
.app-content__inner > .contenthidden > .contentscroll {
height: auto;
overflow: visible;
}
.surface-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.form-stack {
display: grid;
gap: 14px;
}
.form-field {
display: grid;
gap: 8px;
}
.form-field > label,
.form-field > span:first-child {
font-weight: 600;
color: var(--color-text-secondary);
border: 1px solid var(--color-text-secondary);
}
.form-hint {
font-size: 0.88rem;
color: var(--color-text-muted);
}
.form-error {
font-size: 0.88rem;
color: var(--color-danger);
}
.field-error {
border-color: rgba(177, 59, 53, 0.44) !important;
box-shadow: 0 0 0 4px rgba(177, 59, 53, 0.12) !important;
}
.form-actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.button-secondary {
background: rgba(255, 255, 255, 0.86);
color: var(--color-text-primary);
border-color: var(--color-border);
box-shadow: none;
}
.button-secondary:hover {
background: rgba(255, 255, 255, 0.96);
box-shadow: none;
}
.link {
color: var(--color-primary);
cursor: pointer;
}
.link:hover {
color: var(--color-primary-hover);
}
.rc-system {
@@ -52,25 +357,13 @@ button:hover {
}
.rc-self {
color: #ff0000;
font-weight: bold;
color: #c0412c;
font-weight: 700;
}
.rc-partner {
color: #0000ff;
font-weight: bold;
}
.link {
color: var(--color-primary-orange);
cursor: pointer;
}
h1,
h2,
h3 {
margin: 0;
display: block;
color: #2357b5;
font-weight: 700;
}
.multiselect__option--highlight,
@@ -80,61 +373,65 @@ h3 {
.multiselect__option--highlight[data-selected],
.multiselect__option--highlight[data-deselect] {
background: none;
background-color: var(--color-primary-orange);
color: var(--color-text-on-orange);
}
span.button {
padding: 2px 2px;
margin-left: 4px;
cursor: pointer;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
border: 1px solid var(--color-primary-orange);
border-radius: 4px;
transition: background 0.05s;
border: 1px solid transparent;
width: 1.2em;
height: 1.2em;
display: inline-block;
text-align: center;
line-height: 1.2em;
}
span.button:hover {
background: var(--color-primary-orange-light);
color: var(--color-text-secondary);
border: 1px solid var(--color-text-secondary);
background-color: var(--color-primary);
color: var(--color-text-on-accent);
}
.font-color-gender-male {
color: #1E90FF;
color: #1e90ff;
}
.font-color-gender-female {
color: #FF69B4;
color: #d14682;
}
.font-color-gender-transmale {
color: #00CED1;
color: #1f8b9b;
}
.font-color-gender-transfemale {
color: #FFB6C1;
color: #d78398;
}
.font-color-gender-nonbinary {
color: #DAA520;
color: #ba7c1f;
}
main,
.contenthidden {
width: 100%;
height: 100%;
overflow: hidden;
@media (max-width: 960px) {
:root {
--header-height: 56px;
--nav-height: auto;
--footer-height: auto;
}
h1 {
font-size: clamp(1.8rem, 8vw, 2.8rem);
}
h2 {
font-size: clamp(1.35rem, 5vw, 2rem);
}
.contentscroll table {
display: block;
width: 100%;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
}
.contentscroll {
width: 100%;
height: 100%;
overflow: auto;
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,26 +1,51 @@
<template>
<main class="contenthidden">
<div class="contentscroll">
<main class="app-content contenthidden">
<div class="app-content__scroll contentscroll">
<div class="app-content__inner">
<AppSectionBar />
<router-view></router-view>
</div>
</main>
</template>
</div>
</main>
</template>
<script>
import AppSectionBar from './AppSectionBar.vue';
export default {
name: 'AppContent'
name: 'AppContent',
components: {
AppSectionBar
}
};
</script>
<style scoped>
main {
padding: 0;
background-color: #ffffff;
flex: 1;
}
<style scoped>
.app-content {
flex: 1;
height: auto;
min-height: 0;
padding: 0;
overflow: hidden;
}
.contentscroll {
padding: 20px;
}
</style>
.app-content__scroll {
background: transparent;
height: 100%;
min-height: 0;
}
.app-content__inner {
max-width: var(--shell-max-width);
min-height: 100%;
margin: 0 auto;
padding: 14px 18px 12px;
}
@media (max-width: 960px) {
.app-content__inner {
padding: 12px 12px 10px;
}
}
</style>

View File

@@ -1,18 +1,36 @@
<template>
<footer>
<div class="logo" @click="showFalukantDaemonStatus"><img src="/images/icons/logo_color.png"></div>
<div class="window-bar">
<button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle">
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
</div>
<div class="static-block">
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
<a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a>
<footer class="app-footer">
<div class="app-footer__inner">
<div class="footer-system">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
</button>
<span class="footer-caption">
{{ openDialogs.length === 0 ? 'Keine offenen Dialoge' : `${openDialogs.length} Fenster aktiv` }}
</span>
</div>
<div class="window-bar" :class="{ 'window-bar--empty': openDialogs.length === 0 }">
<button
v-for="dialog in openDialogs"
:key="dialog.dialog.name"
class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)"
:title="dialog.dialog.localTitle"
>
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
<span v-if="openDialogs.length === 0" class="window-bar__empty">System bereit</span>
</div>
<div class="static-block">
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
<a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a>
</div>
</div>
</footer>
</template>
@@ -63,18 +81,59 @@ export default {
</script>
<style scoped>
footer {
display: flex;
background-color: var(--color-primary-green);
height: 38px;
width: 100%;
color: #1F2937; /* Dunkles Grau für besseren Kontrast auf hellem Grün */
.app-footer {
flex: 0 0 auto;
padding: 0;
}
.logo,
.window-bar,
.static-block {
text-align: center;
.app-footer__inner {
max-width: var(--shell-max-width);
margin: 0 auto;
display: flex;
align-items: center;
gap: 16px;
min-height: 44px;
padding: 6px 12px;
border-radius: 0;
background:
linear-gradient(180deg, rgba(242, 248, 243, 0.96) 0%, rgba(224, 238, 227, 0.98) 100%);
border-top: 1px solid rgba(120, 195, 138, 0.28);
box-shadow: 0 -6px 18px rgba(93, 64, 55, 0.06);
}
.footer-system {
display: flex;
align-items: center;
gap: 10px;
}
.footer-brand {
min-height: 32px;
padding: 0 10px 0 8px;
background: rgba(120, 195, 138, 0.12);
border: 1px solid rgba(120, 195, 138, 0.22);
color: #24523a;
box-shadow: none;
}
.footer-brand:hover {
background: rgba(120, 195, 138, 0.18);
box-shadow: none;
}
.footer-brand img {
width: 22px;
height: 22px;
}
.footer-brand span {
font-weight: 700;
}
.footer-caption {
font-size: 0.76rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.window-bar {
@@ -83,24 +142,39 @@ footer {
align-items: center;
justify-content: flex-start;
gap: 10px;
padding-left: 10px;
overflow: auto;
min-width: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
border: 1px solid rgba(120, 195, 138, 0.16);
}
.window-bar--empty {
justify-content: center;
}
.window-bar__empty {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.dialog-button {
max-width: 12em;
max-width: 15em;
min-height: 30px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
background: none;
height: 1.8em;
border: 1px solid #0a4337;
box-shadow: 1px 1px 2px #484949;
padding: 0 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
color: var(--color-text-primary);
border: 1px solid rgba(120, 195, 138, 0.18);
box-shadow: none;
}
.dialog-button:hover {
background: rgba(255, 255, 255, 0.92);
}
.dialog-button>img {
@@ -111,16 +185,71 @@ footer {
margin-left: 5px;
}
.logo>img {
width: 36px;
height: 36px;
}
.static-block {
line-height: 38px;
display: flex;
align-items: center;
gap: 18px;
white-space: nowrap;
padding-left: 8px;
border-left: 1px solid rgba(120, 195, 138, 0.22);
}
.static-block>a {
padding-right: 1.5em;
color: #42634e;
font-weight: 600;
}
.static-block > a:hover {
color: #24523a;
}
@media (max-width: 960px) {
.app-footer__inner {
flex-wrap: wrap;
}
.footer-system,
.window-bar,
.static-block {
width: 100%;
}
.footer-system {
justify-content: space-between;
}
.static-block {
justify-content: space-between;
gap: 12px;
padding-left: 0;
border-left: 0;
border-top: 1px solid rgba(120, 195, 138, 0.2);
padding-top: 6px;
}
}
@media (max-width: 640px) {
.app-footer__inner {
gap: 10px;
padding: 8px 10px 10px;
}
.footer-system {
flex-wrap: wrap;
}
.window-bar {
border-radius: var(--radius-md);
padding: 6px;
}
.dialog-button {
min-height: 34px;
}
.static-block {
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style>

View File

@@ -1,15 +1,27 @@
<template>
<header>
<div class="logo"><img src="/images/logos/logo.png" /></div>
<div class="advertisement">Advertisement</div>
<div class="connection-status" v-if="isLoggedIn">
<div class="status-indicator" :class="backendStatusClass">
<span class="status-dot"></span>
<span class="status-text">B</span>
<header class="app-header">
<div class="app-header__inner">
<div class="brand">
<div class="logo"><img src="/images/logos/logo.png" alt="YourPart" /></div>
<div class="brand-copy">
<strong>YourPart</strong>
<span>Community-Plattform</span>
</div>
</div>
<div class="status-indicator" :class="daemonStatusClass">
<span class="status-dot"></span>
<span class="status-text">D</span>
<div class="header-meta">
<div class="header-meta__context">
<span class="header-pill">Beta</span>
</div>
<div class="connection-status" v-if="isLoggedIn">
<div class="status-indicator" :class="backendStatusClass">
<span class="status-dot"></span>
<span class="status-text">Backend</span>
</div>
<div class="status-indicator" :class="daemonStatusClass">
<span class="status-dot"></span>
<span class="status-text">Daemon</span>
</div>
</div>
</div>
</div>
</header>
@@ -43,43 +55,118 @@ export default {
</script>
<style scoped>
header {
.app-header {
position: relative;
flex: 0 0 auto;
padding: 6px 14px;
background:
linear-gradient(180deg, rgba(255, 249, 240, 0.96) 0%, rgba(246, 236, 220, 0.98) 100%);
color: #2b1f14;
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: 0 5px 14px rgba(93, 64, 55, 0.06);
}
.app-header__inner {
max-width: var(--shell-max-width);
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
background-color: #f8a22b;
gap: 16px;
}
.logo, .title, .advertisement {
text-align: center;
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.advertisement {
flex: 1;
.logo {
width: 40px;
height: 40px;
padding: 5px;
border-radius: 12px;
background:
linear-gradient(180deg, rgba(248, 162, 43, 0.18) 0%, rgba(255, 255, 255, 0.76) 100%);
border: 1px solid rgba(248, 162, 43, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
.logo > img {
max-height: 50px;
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 0;
min-width: 0;
}
.brand-copy strong {
font-size: 1rem;
line-height: 1.1;
color: #3a2a1b;
}
.brand-copy span {
font-size: 0.74rem;
color: rgba(95, 75, 57, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-meta {
display: flex;
align-items: center;
gap: 12px;
}
.header-meta__context {
display: flex;
align-items: center;
gap: 10px;
}
.header-pill {
padding: 5px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
border: 1px solid rgba(248, 162, 43, 0.24);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8a5411;
}
.connection-status {
display: flex;
align-items: center;
margin-left: 10px;
gap: 5px;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 6pt;
font-weight: 500;
gap: 8px;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
border: 1px solid rgba(93, 64, 55, 0.1);
background: rgba(255, 255, 255, 0.62);
}
.status-dot {
width: 6px;
height: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
animation: pulse 2s infinite;
}
@@ -100,23 +187,23 @@ header {
}
.status-connected {
background-color: rgba(76, 175, 80, 0.1);
color: #2e7d32;
background-color: rgba(76, 175, 80, 0.12);
color: #245b2c;
}
.status-connecting {
background-color: rgba(255, 152, 0, 0.1);
color: #f57c00;
background-color: rgba(255, 152, 0, 0.12);
color: #8b5e0d;
}
.status-disconnected {
background-color: rgba(244, 67, 54, 0.1);
color: #d32f2f;
background-color: rgba(244, 67, 54, 0.12);
color: #8f2c27;
}
.status-error {
background-color: rgba(244, 67, 54, 0.1);
color: #d32f2f;
background-color: rgba(244, 67, 54, 0.12);
color: #8f2c27;
}
@keyframes pulse {
@@ -124,4 +211,53 @@ header {
50% { opacity: 0.5; }
100% { opacity: 1; }
}
@media (max-width: 960px) {
.app-header {
padding: 6px 10px;
}
.app-header__inner {
gap: 10px;
flex-wrap: wrap;
}
.header-meta {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.header-meta__context {
flex-wrap: wrap;
}
.brand-copy span {
font-size: 0.76rem;
white-space: normal;
}
}
@media (max-width: 640px) {
.app-header__inner {
align-items: flex-start;
}
.brand {
width: 100%;
}
.header-meta {
gap: 8px;
}
.connection-status {
width: 100%;
flex-wrap: wrap;
}
.status-indicator {
min-height: 32px;
}
}
</style>

View File

@@ -1,26 +1,44 @@
<template>
<nav>
<ul>
<nav
ref="navRoot"
class="app-navigation"
:class="{ 'app-navigation--suppress-hover': suppressHover }"
>
<div class="nav-primary">
<ul>
<!-- Hauptmenü -->
<li
v-for="(item, key) in menu"
:key="key"
class="mainmenuitem"
@click="handleItem(item, $event)"
:class="{ 'mainmenuitem--active': isItemActive(item), 'mainmenuitem--expanded': isMainExpanded(key) }"
tabindex="0"
role="button"
:aria-haspopup="hasTopLevelSubmenu(item) ? 'menu' : undefined"
:aria-expanded="hasTopLevelSubmenu(item) ? String(isMainExpanded(key)) : undefined"
@click="handleItem(item, $event, key)"
@keydown.enter.prevent="handleItem(item, $event, key)"
@keydown.space.prevent="handleItem(item, $event, key)"
>
<span
v-if="item.icon"
:style="`background-image:url('/images/icons/${item.icon}')`"
class="menu-icon"
>&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 -->
<ul v-if="item.children" class="submenu1">
<!-- Untermenü Ebene 1 -->
<ul v-if="hasTopLevelSubmenu(item)" class="submenu1" :class="{ 'submenu1--open': isMainExpanded(key) }">
<li
v-for="(subitem, subkey) in item.children"
:key="subkey"
@click="handleItem(subitem, $event)"
tabindex="0"
role="menuitem"
:class="{ 'submenu1__item--expanded': isSubExpanded(`${key}:${subkey}`) }"
@click="handleSubItem(subitem, subkey, key, $event)"
@keydown.enter.prevent="handleSubItem(subitem, subkey, key, $event)"
@keydown.space.prevent="handleSubItem(subitem, subkey, key, $event)"
>
<span
v-if="subitem.icon"
@@ -29,7 +47,7 @@
>&nbsp;</span>
<span>{{ subitem?.label || $t(`navigation.m-${key}.${subkey}`) }}</span>
<span
v-if="subkey === 'forum' || subkey === 'vocabtrainer' || subitem.children"
v-if="hasSecondLevelSubmenu(subitem, subkey)"
class="subsubmenu"
>&#x25B6;</span>
@@ -37,11 +55,16 @@
<ul
v-if="subkey === 'forum' && forumList.length"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
v-for="forum in forumList"
:key="forum.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openForum', params: forum.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openForum', params: forum.id }, $event)"
>
{{ forum.name }}
</li>
@@ -51,16 +74,25 @@
<ul
v-else-if="subkey === 'vocabtrainer' && vocabLanguagesList.length"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
tabindex="0"
role="menuitem"
@click="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
@keydown.enter.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
@keydown.space.prevent="handleItem({ path: '/socialnetwork/vocab/new' }, $event)"
>
{{ $t('navigation.m-sprachenlernen.m-vocabtrainer.newLanguage') }}
</li>
<li
v-for="lang in vocabLanguagesList"
:key="lang.id"
tabindex="0"
role="menuitem"
@click="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
@keydown.enter.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
@keydown.space.prevent="handleItem({ path: `/socialnetwork/vocab/${lang.id}` }, $event)"
>
{{ lang.name }}
</li>
@@ -70,11 +102,16 @@
<ul
v-else-if="subitem.children"
class="submenu2"
:class="{ 'submenu2--open': isSubExpanded(`${key}:${subkey}`) }"
>
<li
v-for="(subsubitem, subsubkey) in subitem.children"
:key="subsubkey"
tabindex="0"
role="menuitem"
@click="handleItem(subsubitem, $event)"
@keydown.enter.prevent="handleItem(subsubitem, $event)"
@keydown.space.prevent="handleItem(subsubitem, $event)"
>
<span
v-if="subsubitem.icon"
@@ -91,17 +128,29 @@
v-if="item.showLoggedinFriends === 1 && friendsList.length"
v-for="friend in friendsList"
:key="friend.id"
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
>
{{ friend.username }}
<ul class="submenu2">
<li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openChat', params: friend.id }, $event)"
>
{{ $t('navigation.m-friends.chat') }}
</li>
<li
tabindex="0"
role="menuitem"
@click="handleItem({ action: 'openProfile', params: friend.id }, $event)"
@keydown.enter.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
@keydown.space.prevent="handleItem({ action: 'openProfile', params: friend.id }, $event)"
>
{{ $t('navigation.m-friends.profile') }}
</li>
@@ -109,13 +158,14 @@
</li>
</ul>
</li>
</ul>
</ul>
</div>
<div class="right-block">
<span @click="accessMailbox" class="mailbox"></span>
<button type="button" @click="accessMailbox" class="mailbox" aria-label="Mailbox"></button>
<span class="logoutblock">
<span class="username">{{ user.username }}</span>
<span @click="logout" class="menuitem">
<span class="menuitem" @click="logout">
{{ $t('navigation.logout') }}
</span>
</span>
@@ -127,6 +177,7 @@
import { mapGetters, mapActions } from 'vuex';
import { createApp } from 'vue';
import apiClient from '@/utils/axios.js';
import { EventBus } from '@/utils/eventBus.js';
import RandomChatDialog from '../dialogues/chat/RandomChatDialog.vue';
import MultiChatDialog from '../dialogues/chat/MultiChatDialog.vue';
@@ -146,7 +197,14 @@ export default {
return {
forumList: [],
friendsList: [],
vocabLanguagesList: []
vocabLanguagesList: [],
expandedMainKey: null,
expandedSubKey: null,
pinnedMainKey: null,
pinnedSubKey: null,
suppressHover: false,
hoverReleaseTimer: null,
isMobileNav: false
};
},
computed: {
@@ -156,6 +214,9 @@ export default {
menuNeedsUpdate(newVal) {
if (newVal) this.loadMenu();
},
$route() {
this.collapseMenus();
},
socket(newSocket) {
if (newSocket) {
newSocket.on('forumschanged', this.fetchForums);
@@ -171,6 +232,10 @@ export default {
this.fetchFriends();
this.fetchVocabLanguages();
}
this.updateViewportState();
window.addEventListener('resize', this.updateViewportState);
document.addEventListener('click', this.handleDocumentClick);
document.addEventListener('keydown', this.handleDocumentKeydown);
},
beforeUnmount() {
const sock = this.socket;
@@ -179,10 +244,128 @@ export default {
sock.off('friendloginchanged');
sock.off('reloadmenu');
}
window.removeEventListener('resize', this.updateViewportState);
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleDocumentKeydown);
if (this.hoverReleaseTimer) {
clearTimeout(this.hoverReleaseTimer);
}
},
methods: {
...mapActions(['loadMenu', 'logout']),
updateViewportState() {
this.isMobileNav = window.innerWidth <= 960;
if (!this.isMobileNav) {
this.expandedMainKey = null;
this.expandedSubKey = null;
}
},
isMainExpanded(key) {
return this.isMobileNav
? this.expandedMainKey === key
: this.pinnedMainKey === key;
},
isSubExpanded(key) {
return this.isMobileNav
? this.expandedSubKey === key
: this.pinnedSubKey === key;
},
toggleMain(key) {
this.expandedMainKey = this.expandedMainKey === key ? null : key;
this.expandedSubKey = null;
},
toggleSub(key) {
this.expandedSubKey = this.expandedSubKey === key ? null : key;
},
togglePinnedMain(key) {
this.pinnedMainKey = this.pinnedMainKey === key ? null : key;
this.pinnedSubKey = null;
},
togglePinnedSub(key) {
this.pinnedSubKey = this.pinnedSubKey === key ? null : key;
},
collapseMenus() {
this.expandedMainKey = null;
this.expandedSubKey = null;
this.pinnedMainKey = null;
this.pinnedSubKey = null;
this.suppressHover = true;
if (this.hoverReleaseTimer) {
clearTimeout(this.hoverReleaseTimer);
}
this.hoverReleaseTimer = window.setTimeout(() => {
this.suppressHover = false;
this.hoverReleaseTimer = null;
}, 180);
this.$nextTick(() => {
if (document.activeElement && typeof document.activeElement.blur === 'function') {
document.activeElement.blur();
}
});
},
handleDocumentClick(event) {
const root = this.$refs.navRoot;
if (!root || root.contains(event.target)) {
return;
}
this.collapseMenus();
},
handleDocumentKeydown(event) {
if (event.key === 'Escape') {
this.collapseMenus();
}
},
hasChildren(item) {
if (!item?.children) {
return false;
}
if (Array.isArray(item.children)) {
return item.children.length > 0;
}
return Object.keys(item.children).length > 0;
},
hasTopLevelSubmenu(item) {
return this.hasChildren(item) || (item?.showLoggedinFriends === 1 && this.friendsList.length > 0);
},
hasSecondLevelSubmenu(subitem, subkey) {
if (subkey === 'forum') {
return this.forumList.length > 0;
}
if (subkey === 'vocabtrainer') {
return this.vocabLanguagesList.length > 0;
}
return this.hasChildren(subitem);
},
isItemActive(item) {
if (!item?.path || !this.$route?.path) {
return false;
}
if (item.path === '/') {
return this.$route.path === '/';
}
return this.$route.path === item.path || this.$route.path.startsWith(`${item.path}/`);
},
openMultiChat() {
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
const exampleRooms = [
@@ -199,6 +382,21 @@ export default {
}
},
accessMailbox() {
const openMessages = () => {
EventBus.emit('open-falukant-messages');
};
if (this.$route?.path?.startsWith('/falukant')) {
openMessages();
return;
}
this.$router.push({ name: 'FalukantOverview' }).then(() => {
window.setTimeout(openMessages, 150);
});
},
async fetchForums() {
try {
const res = await apiClient.get('/api/forum');
@@ -250,11 +448,19 @@ export default {
* 3) Bei `action`: custom action aufrufen
* 4) Sonst: normale Router-Navigation
*/
handleItem(item, event) {
handleItem(item, event, key = null) {
event.stopPropagation();
// 1) nur aufklappen, wenn es echte Untermenüs gibt (nicht bei leerem children wie bei Startseite)
if (item.children && Object.keys(item.children).length > 0) return;
if (key && this.hasTopLevelSubmenu(item)) {
if (this.isMobileNav) {
this.toggleMain(key);
} else {
this.togglePinnedMain(key);
}
return;
}
if (this.hasChildren(item)) return;
// 2) view → Dialog/Window
if (item.view) {
@@ -271,18 +477,38 @@ export default {
} else {
console.error(`Dialog '${item.class}' gefunden, aber keine open()-Methode verfügbar.`);
}
this.collapseMenus();
return;
}
// 3) custom action (openForum, openChat, ...)
if (item.action && typeof this[item.action] === 'function') {
return this[item.action](item.params, event);
this[item.action](item.params, event);
this.collapseMenus();
return;
}
// 4) StandardNavigation
if (item.path) {
this.$router.push(item.path);
this.collapseMenus();
}
},
handleSubItem(item, subkey, parentKey, event) {
event.stopPropagation();
const compoundKey = `${parentKey}:${subkey}`;
if (this.hasSecondLevelSubmenu(item, subkey)) {
if (this.isMobileNav) {
this.toggleSub(compoundKey);
} else {
this.togglePinnedSub(compoundKey);
}
return;
}
this.handleItem(item, event);
}
}
};
@@ -291,42 +517,105 @@ export default {
<style lang="scss" scoped>
@import '../assets/styles.scss';
nav,
nav > ul {
.app-navigation,
.nav-primary > ul {
display: flex;
justify-content: space-between;
background-color: #f8a22b;
color: #000;
padding: 0;
margin: 0;
cursor: pointer;
flex-direction: row;
}
.app-navigation {
width: 100%;
max-width: none;
margin: 0 auto;
align-items: center;
gap: 10px;
padding: 6px 12px;
flex-wrap: wrap;
border-radius: 0;
background:
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
border-top: 1px solid rgba(93, 64, 55, 0.08);
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.46);
color: var(--color-text-primary);
z-index: 999;
}
.nav-primary {
flex: 1;
min-width: 0;
overflow: visible;
position: relative;
z-index: 1;
}
.nav-primary > ul {
min-width: 0;
justify-content: flex-start;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
nav > ul > li {
padding: 0 1em;
line-height: 2.5em;
transition: background-color 0.25s;
.mainmenuitem {
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 12px;
line-height: 1;
cursor: pointer;
border-radius: 999px;
border: 1px solid transparent;
transition: background-color 0.25s, color 0.25s, transform 0.2s, border-color 0.25s, box-shadow 0.25s;
}
nav > ul > li:hover {
background-color: #f8a22b;
.mainmenuitem:focus-visible,
.submenu1 > li:focus-visible,
.submenu2 > li:focus-visible,
.mailbox:focus-visible,
.menuitem:focus-visible {
outline: 3px solid rgba(120, 195, 138, 0.34);
outline-offset: 2px;
}
.mainmenuitem:hover {
background-color: rgba(248, 162, 43, 0.16);
border-color: rgba(248, 162, 43, 0.2);
transform: translateY(-1px);
}
.mainmenuitem:hover > span {
color: var(--color-primary);
}
.mainmenuitem--expanded {
background-color: rgba(248, 162, 43, 0.16);
border-color: rgba(248, 162, 43, 0.2);
}
.mainmenuitem--active {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(248, 162, 43, 0.22);
box-shadow: 0 6px 14px rgba(93, 64, 55, 0.05);
}
.mainmenuitem__label {
white-space: nowrap;
}
nav > ul > li:hover > span {
color: #000;
}
nav > ul > li:hover > ul {
display: inline-block;
.mainmenuitem__caret {
margin-left: 6px;
font-size: 0.7rem;
color: rgba(95, 75, 57, 0.7);
}
a {
@@ -335,17 +624,29 @@ a {
.right-block {
display: flex;
gap: 10px;
align-items: center;
gap: 12px;
padding-left: 10px;
margin-left: auto;
flex: 0 0 auto;
border-left: 1px solid rgba(93, 64, 55, 0.12);
position: relative;
z-index: 3;
background:
linear-gradient(180deg, rgba(249, 236, 225, 0.98) 0%, rgba(246, 228, 212, 0.98) 100%);
}
.logoutblock {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.menuitem {
cursor: pointer;
color: #5D4037;
color: var(--color-primary);
font-weight: 700;
}
.mailbox {
@@ -353,20 +654,29 @@ a {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
padding-left: 24px;
text-align: left;
width: 38px;
height: 38px;
border-radius: 999px;
background-color: rgba(120, 195, 138, 0.12);
border: 1px solid rgba(93, 64, 55, 0.1);
box-shadow: none;
min-height: 0;
padding: 0;
}
.mainmenuitem {
position: relative;
}
.mainmenuitem { position: relative; font-weight: 700; }
.submenu1 {
position: absolute;
border: 1px solid #5D4037;
background-color: #f8a22b;
display: block;
border: 1px solid rgba(93, 64, 55, 0.12);
background: rgba(255, 252, 247, 0.99);
left: 0;
top: 2.5em;
top: calc(100% + 10px);
min-width: 240px;
padding: 10px;
border-radius: var(--radius-lg);
box-shadow: 0 18px 30px rgba(93, 64, 55, 0.14);
max-height: 0;
overflow: visible;
opacity: 0;
@@ -385,16 +695,27 @@ a {
visibility 0s;
}
.mainmenuitem--expanded .submenu1 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.submenu1 > li {
padding: 0.5em;
line-height: 1em;
color: #5D4037;
display: block;
padding: 0.75em 0.9em;
line-height: 1.1em;
color: var(--color-text-secondary);
position: relative;
border-radius: 14px;
}
.submenu1 > li:hover {
color: #000;
background-color: #f8a22b;
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.12);
}
.menu-icon,
@@ -407,7 +728,7 @@ a {
.menu-icon {
width: 24px;
height: 24px;
margin-right: 3px;
margin-right: 8px;
}
.submenu-icon {
@@ -419,10 +740,15 @@ a {
.submenu2 {
position: absolute;
background-color: #f8a22b;
left: 100%;
display: block;
background: rgba(255, 252, 247, 0.98);
left: calc(100% + 8px);
top: 0;
border: 1px solid #5D4037;
min-width: 230px;
padding: 8px;
border-radius: var(--radius-lg);
border: 1px solid rgba(71, 52, 35, 0.12);
box-shadow: 0 14px 24px rgba(93, 64, 55, 0.12);
max-height: 0;
overflow: hidden;
opacity: 0;
@@ -441,15 +767,43 @@ a {
visibility 0s;
}
.submenu1__item--expanded .submenu2 {
max-height: 500px;
opacity: 1;
visibility: visible;
transition: max-height 0.25s ease-in-out,
opacity 0.05s ease-in-out,
visibility 0s;
}
.app-navigation--suppress-hover .mainmenuitem:hover .submenu1,
.app-navigation--suppress-hover .submenu1 > li:hover .submenu2 {
max-height: 0;
opacity: 0;
visibility: hidden;
}
.submenu1__item--expanded {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.08);
}
.submenu2 > li {
padding: 0.5em;
padding: 0.75em 0.9em;
line-height: 1em;
color: #5D4037;
color: var(--color-text-secondary);
border-radius: 14px;
}
.submenu2 > li:hover {
color: #000;
background-color: #f8a22b;
color: var(--color-text-primary);
background-color: rgba(120, 195, 138, 0.14);
}
.submenu1 > li:focus-visible,
.submenu2 > li:focus-visible {
color: var(--color-text-primary);
background-color: rgba(248, 162, 43, 0.12);
}
.subsubmenu {
@@ -457,4 +811,103 @@ a {
font-size: 8pt;
margin-right: -4px;
}
.username {
font-weight: 800;
color: var(--color-text-secondary);
}
@media (max-width: 960px) {
.app-navigation {
margin: 0;
flex-direction: column;
flex-wrap: nowrap;
padding: 8px 10px;
align-items: stretch;
}
.nav-primary,
.nav-primary > ul,
.right-block {
width: 100%;
}
.nav-primary {
overflow-x: auto;
overflow-y: visible;
}
.nav-primary > ul {
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
.right-block {
justify-content: space-between;
padding-left: 0;
margin-left: 0;
border-left: 0;
padding-top: 6px;
border-top: 1px solid rgba(93, 64, 55, 0.1);
}
.logoutblock {
align-items: flex-start;
}
.mainmenuitem {
min-height: 42px;
width: calc(50% - 4px);
justify-content: flex-start;
padding: 0 14px;
}
.submenu1,
.submenu2 {
position: static;
min-width: 100%;
margin-top: 8px;
max-height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
padding: 0 10px;
}
.submenu1--open,
.submenu2--open {
max-height: 1200px;
opacity: 1;
visibility: visible;
padding: 10px;
}
.submenu1 > li,
.submenu2 > li {
min-height: 42px;
display: flex;
align-items: center;
}
.mailbox {
width: 42px;
height: 42px;
}
}
@media (max-width: 640px) {
.mainmenuitem {
width: 100%;
}
.right-block {
flex-wrap: wrap;
gap: 10px;
}
.logoutblock {
width: 100%;
}
}
</style>

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

View File

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

View File

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

View File

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

View File

@@ -210,6 +210,14 @@ export default {
},
};
},
watch: {
branchId: {
immediate: false,
handler() {
this.loadDirector();
},
},
},
async mounted() {
await this.loadDirector();
},
@@ -256,11 +264,17 @@ export default {
},
speedLabel(value) {
const key = value == null ? 'unknown' : String(value);
if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—';
if (typeof value === 'object') {
const k = value.tr ?? value.id ?? 'unknown';
const tKey = `falukant.branch.transport.speed.${k}`;
const t = this.$t(tKey);
return (t && t !== tKey) ? t : String(k);
}
const key = String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return value;
return translated;
return (!translated || translated === tKey) ? key : translated;
},
openNewDirectorDialog() {

View File

@@ -251,13 +251,6 @@
return new Date(a.eta).getTime() - new Date(b.eta).getTime();
});
},
speedLabel(value) {
const key = value == null ? 'unknown' : String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return value;
return translated;
},
},
async mounted() {
await this.loadInventory();
@@ -274,6 +267,19 @@
}
},
methods: {
speedLabel(value) {
if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—';
if (typeof value === 'object') {
const k = value.tr ?? value.id ?? 'unknown';
const tKey = `falukant.branch.transport.speed.${k}`;
const t = this.$t(tKey);
return (t && t !== tKey) ? t : String(k);
}
const key = String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
return (!translated || translated === tKey) ? key : translated;
},
async loadInventory() {
try {
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
@@ -287,25 +293,24 @@
}
},
async loadPricesForInventory() {
for (const item of this.inventory) {
const itemKey = `${item.region.id}-${item.product.id}-${item.quality}`;
if (this.loadingPrices.has(itemKey)) continue;
this.loadingPrices.add(itemKey);
try {
// Aktueller Preis basierend auf sellCost
const currentPrice = item.product.sellCost || 0;
const { data } = await apiClient.get('/api/falukant/products/prices-in-cities', {
params: {
productId: item.product.id,
currentPrice: currentPrice
}
});
this.$set(item, 'betterPrices', data || []);
} catch (error) {
console.error(`Error loading prices for item ${itemKey}:`, error);
this.$set(item, 'betterPrices', []);
} finally {
this.loadingPrices.delete(itemKey);
if (this.inventory.length === 0) return;
const currentRegionId = this.inventory[0]?.region?.id ?? null;
const items = this.inventory.map(item => ({
productId: item.product.id,
currentPrice: item.product.sellCost || 0
}));
try {
const { data } = await apiClient.post('/api/falukant/products/prices-in-cities-batch', {
currentRegionId,
items
});
for (const item of this.inventory) {
item.betterPrices = data && data[item.product.id] ? data[item.product.id] : [];
}
} catch (error) {
console.error('Error loading prices for inventory:', error);
for (const item of this.inventory) {
item.betterPrices = [];
}
}
},

View File

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

View File

@@ -24,8 +24,31 @@ export default {
},
computed: {
falukantData() {
const d = this.data;
if (d && typeof d === 'object' && 'characterName' in d && 'money' in d) return d;
// normalize incoming API payload: accept both camelCase and snake_case
const raw = this.data;
if (!raw || typeof raw !== 'object') return null;
const pick = (obj, camel, snake) => {
if (camel in obj && obj[camel] !== undefined) return obj[camel];
if (snake in obj && obj[snake] !== undefined) return obj[snake];
return undefined;
};
const normalized = {
characterName: pick(raw, 'characterName', 'character_name') ?? pick(raw, 'nameWithoutTitle', 'name_without_title'),
nameWithoutTitle: pick(raw, 'nameWithoutTitle', 'name_without_title'),
titleLabelTr: pick(raw, 'titleLabelTr', 'title_label_tr'),
gender: pick(raw, 'gender', 'gender'),
age: pick(raw, 'age', 'age'),
money: pick(raw, 'money', 'money'),
unreadNotificationsCount: pick(raw, 'unreadNotificationsCount', 'unread_notifications_count'),
childrenCount: pick(raw, 'childrenCount', 'children_count'),
// keep all original keys as fallback for any other usage
...raw
};
// sanity: require at least a name and money
if ((normalized.characterName || normalized.nameWithoutTitle) && (normalized.money !== undefined)) return normalized;
return null;
},
falukantDisplayName() {
@@ -46,8 +69,8 @@ export default {
if (g == null || g === '') return '—';
// Altersabhängige, (auf Wunsch) altertümlichere Bezeichnungen
const age = Number(this.falukantData?.age);
const group = this._getAgeGroupKey(age);
const years = this._ageYearsFromWidgetValue(this.falukantData?.age);
const group = years == null ? null : this._getAgeGroupKey(years);
if (group && (g === 'female' || g === 'male')) {
const key = `falukant.genderAge.${g}.${group}`;
const t = this.$t(key);
@@ -62,19 +85,38 @@ export default {
falukantAgeLabel() {
const ageValue = this.falukantData?.age;
if (ageValue == null) return '—';
const numAge = Number(ageValue);
return `${numAge} ${this.$t('falukant.overview.metadata.years')}`;
const years = this._ageYearsFromWidgetValue(ageValue);
if (years == null) return '—';
return `${years} ${this.$t('falukant.overview.metadata.years')}`;
}
},
methods: {
/**
* Backend liefert für Falukant das Alter als (Spiel-)Tage.
* In diesem Spiel entspricht 1 (Spiel-)Tag einem Jahr, damit die Alterung spielbar schnell ist.
*
* Daher ist der übergebene Tageswert direkt das Alter in (Spiel-)Jahren.
*/
_ageYearsFromWidgetValue(ageValue) {
const n = Number(ageValue);
if (Number.isNaN(n)) return null;
// Spiel-Zeit: 1 Tag = 1 Jahr
const years = Math.floor(n);
return Number.isFinite(years) ? years : null;
},
_getAgeGroupKey(age) {
const a = Number(age);
if (Number.isNaN(a)) return null;
// Pro Sprache konfigurierbare Schwellenwerte aus i18n.
// Format: "key:maxAge|key2:maxAge2|..." (maxAge exklusiv, letzte Gruppe sollte hoch gesetzt sein)
const raw = this.$t('falukant.genderAge.ageGroups');
const parsed = typeof raw === 'string' ? raw : '';
// Achtung: vue-i18n kann Strings mit `|` als Plural/Choice-Format interpretieren.
// Dann würde `$t(...)` nur bis zum ersten `|` liefern (z.B. "toddler:4").
// Deshalb lesen wir den Rohwert direkt aus den registrierten Messages.
const msgAgeGroups = this?.$i18n?.messages?.[this?.$i18n?.locale]?.falukant?.genderAge?.ageGroups;
const raw = typeof msgAgeGroups === 'string' ? msgAgeGroups : this.$t('falukant.genderAge.ageGroups');
const parsed = typeof raw === 'string' ? raw : '';
const rules = parsed.split('|')
.map(part => part.trim())
.filter(Boolean)

View File

@@ -1,7 +1,12 @@
<template>
<DialogWidget ref="dialog" title="passwordReset.title" :isTitleTranslated="true" :show-close=true :buttons="buttons" @close="closeDialog" @reset="resetPassword" name="PasswordReset">
<div>
<label>{{ $t("passwordReset.email") }} <input type="email" v-model="email" required /></label>
<div class="form-stack">
<div class="form-field">
<label for="password-reset-email">{{ $t("passwordReset.email") }}</label>
<input id="password-reset-email" type="email" v-model="email" required :class="{ 'field-error': emailTouched && !isEmailValid }" />
<span class="form-hint">Wir senden den Link an die hinterlegte E-Mail-Adresse.</span>
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gueltige E-Mail-Adresse eingeben.</span>
</div>
</div>
</DialogWidget>
</template>
@@ -9,6 +14,7 @@
<script>
import apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'PasswordResetDialog',
@@ -18,9 +24,21 @@ export default {
data() {
return {
email: '',
buttons: [{ text: 'passwordReset.reset', action: 'reset' }]
emailTouched: false,
buttons: [{ text: 'passwordReset.reset', action: 'reset', disabled: true }]
};
},
computed: {
isEmailValid() {
return /\S+@\S+\.\S+/.test(this.email);
}
},
watch: {
email() {
this.emailTouched = true;
this.buttons[0].disabled = !this.isEmailValid;
}
},
methods: {
open() {
this.$refs.dialog.open();
@@ -29,15 +47,18 @@ export default {
this.$refs.dialog.close();
},
async resetPassword() {
if (!this.isEmailValid) {
return;
}
try {
await apiClient.post('/api/users/requestPasswordReset', {
email: this.email
});
this.$refs.dialog.close();
alert(this.$t("passwordReset.success"));
showSuccess(this, 'tr:passwordReset.success');
} catch (error) {
console.error('Error resetting password:', error);
alert(this.$t("passwordReset.failure"));
showApiError(this, error, 'tr:passwordReset.failure');
}
}
}

View File

@@ -2,18 +2,27 @@
<DialogWidget ref="dialog" title="register.title" :show-close="true" :buttons="buttons" :modal="true"
@close="closeDialog" @register="register" width="35em" height="33em" name="RegisterDialog"
:isTitleTranslated="true">
<div class="form-content">
<div>
<label>{{ $t("register.email") }}<input type="email" v-model="email" /></label>
<div class="form-content form-stack">
<div class="form-field">
<label for="register-email">{{ $t("register.email") }}</label>
<input id="register-email" type="email" v-model="email" :class="{ 'field-error': emailTouched && !isEmailValid }" />
<span v-if="emailTouched && !isEmailValid" class="form-error">Bitte eine gueltige E-Mail-Adresse eingeben.</span>
</div>
<div>
<label>{{ $t("register.username") }}<input type="text" v-model="username" /></label>
<div class="form-field">
<label for="register-username">{{ $t("register.username") }}</label>
<input id="register-username" type="text" v-model="username" :class="{ 'field-error': usernameTouched && !isUsernameValid }" />
<span v-if="usernameTouched && !isUsernameValid" class="form-error">Der Benutzername sollte mindestens 3 Zeichen haben.</span>
</div>
<div>
<label>{{ $t("register.password") }}<input type="password" v-model="password" /></label>
<div class="form-field">
<label for="register-password">{{ $t("register.password") }}</label>
<input id="register-password" type="password" v-model="password" :class="{ 'field-error': passwordTouched && !isPasswordValid }" />
<span class="form-hint">Mindestens 8 Zeichen.</span>
<span v-if="passwordTouched && !isPasswordValid" class="form-error">Das Passwort ist noch zu kurz.</span>
</div>
<div>
<label>{{ $t("register.repeatPassword") }}<input type="password" v-model="repeatPassword" /></label>
<div class="form-field">
<label for="register-repeat-password">{{ $t("register.repeatPassword") }}</label>
<input id="register-repeat-password" type="password" v-model="repeatPassword" :class="{ 'field-error': repeatPasswordTouched && !doPasswordsMatch }" />
<span v-if="repeatPasswordTouched && !doPasswordsMatch" class="form-error">Die Passwoerter stimmen nicht ueberein.</span>
</div>
<SelectDropdownWidget labelTr="settings.personal.label.language" :v-model="language"
tooltipTr="settings.personal.tooltip.language" :list="languages" :value="language" />
@@ -26,6 +35,7 @@ import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
import DialogWidget from '@/components/DialogWidget.vue';
import SelectDropdownWidget from '@/components/form/SelectDropdownWidget.vue';
import { showApiError, showError } from '@/utils/feedback.js';
export default {
name: 'RegisterDialog',
@@ -41,6 +51,10 @@ export default {
repeatPassword: '',
language: null,
languages: [],
emailTouched: false,
usernameTouched: false,
passwordTouched: false,
repeatPasswordTouched: false,
buttons: [
{ text: 'register.close', action: 'close' },
{ text: 'register.register', action: 'register', disabled: !this.canRegister }
@@ -48,11 +62,35 @@ export default {
};
},
computed: {
isEmailValid() {
return /\S+@\S+\.\S+/.test(this.email);
},
isUsernameValid() {
return this.username.trim().length >= 3;
},
isPasswordValid() {
return this.password.length >= 8;
},
doPasswordsMatch() {
return Boolean(this.password) && this.password === this.repeatPassword;
},
canRegister() {
return this.password && this.repeatPassword && this.password === this.repeatPassword;
return this.isEmailValid && this.isUsernameValid && this.isPasswordValid && this.doPasswordsMatch && this.language;
}
},
watch: {
email() {
this.emailTouched = true;
},
username() {
this.usernameTouched = true;
},
password() {
this.passwordTouched = true;
},
repeatPassword() {
this.repeatPasswordTouched = true;
},
canRegister(newValue) {
this.buttons[1].disabled = !newValue;
}
@@ -82,7 +120,7 @@ export default {
},
async register() {
if (!this.canRegister) {
this.$root.$refs.errrorDialog.open('tr:register.passwordMismatch');
showError(this, 'tr:register.passwordMismatch');
return;
}
@@ -99,14 +137,14 @@ export default {
this.$refs.dialog.close();
this.$router.push('/activate');
} else {
this.$root.$refs.errrorDialog.open("tr:register.failure");
showError(this, 'tr:register.failure');
}
} catch (error) {
if (error.response && error.response.status === 409) {
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
showError(this, `tr:register.${error.response.data.error}`);
} else {
console.error('Error registering user:', error);
this.$root.$refs.errrorDialog.open('tr:register.' + error.response.data.error);
showApiError(this, error, 'tr:register.failure');
}
}
},
@@ -125,21 +163,11 @@ export default {
</script>
<style scoped>
.form-content>div {
margin-bottom: 1em;
}
label {
display: block;
margin-bottom: 0.5em;
}
input[type="email"],
input[type="text"],
input[type="password"],
select {
width: 100%;
padding: 0.5em;
box-sizing: border-box;
}
</style>

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">
<div class="dialog-widget-content">
<div class="multi-chat-top">
<select v-model="selectedRoom" class="room-select">
<div class="room-left-controls">
<select v-model="selectedRoom" class="room-select">
<option v-for="room in rooms" :key="room.id" :value="room.id">{{ room.title }}</option>
</select>
</select>
<button class="create-room-toggle-btn" type="button" @click="toggleRoomCreatePanel">
{{ showRoomCreatePanel ? $t('chat.multichat.createRoom.toggleShowChat') : $t('chat.multichat.createRoom.toggleCreateRoom') }}
</button>
</div>
<div class="right-controls">
<div class="status" :class="statusType">
<span class="dot"></span>
@@ -24,14 +29,14 @@
</label>
<div class="opts-divider" v-if="isAdmin"></div>
<div class="opts-row" v-if="isAdmin">
<button class="opts-btn" type="button" @click="reloadRoomsAdmin">Räume neu laden</button>
<button class="opts-btn" type="button" @click="reloadRoomsAdmin">{{ $t('chat.multichat.reloadRooms') }}</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="!showColorPicker" class="multi-chat-body">
<div class="multi-chat-output" ref="output" @mouseenter="mouseOverOutput = true"
<div v-if="!showRoomCreatePanel" class="multi-chat-output" ref="output" @mouseenter="mouseOverOutput = true"
@mouseleave="mouseOverOutput = false">
<div v-for="msg in messages" :key="msg.id" class="chat-message">
<template v-if="msg.type === 'scream'">
@@ -53,6 +58,104 @@
</template>
</div>
</div>
<div v-else class="room-create-panel">
<div class="room-create-title">{{ $t('chat.multichat.createRoom.title') }}</div>
<div class="room-create-grid">
<label>
{{ $t('chat.multichat.createRoom.labels.roomName') }} *
<input v-model.trim="roomCreateForm.roomName" type="text" :placeholder="$t('chat.multichat.createRoom.placeholders.roomName')"
:class="{ 'invalid-input': roomCreateValidation.roomName }" />
<span v-if="roomCreateValidation.roomName" class="room-create-error">{{ roomCreateValidation.roomName }}</span>
</label>
<label>
{{ $t('chat.multichat.createRoom.labels.visibility') }}
<select v-model="roomCreateForm.visibility">
<option value="">{{ $t('chat.multichat.createRoom.options.none') }}</option>
<option value="public">{{ $t('chat.multichat.createRoom.options.visibilityPublic') }}</option>
<option value="private">{{ $t('chat.multichat.createRoom.options.visibilityPrivate') }}</option>
</select>
</label>
<label>
{{ $t('chat.multichat.createRoom.labels.gender') }}
<select v-model="roomCreateForm.gender">
<option value="">{{ $t('chat.multichat.createRoom.options.none') }}</option>
<option value="m">{{ $t('chat.multichat.createRoom.options.genderMale') }}</option>
<option value="f">{{ $t('chat.multichat.createRoom.options.genderFemale') }}</option>
<option value="any">{{ $t('chat.multichat.createRoom.options.genderAny') }}</option>
</select>
</label>
<label>
{{ $t('chat.multichat.createRoom.labels.minAge') }}
<input v-model.number="roomCreateForm.minAge" type="number" min="0"
:class="{ 'invalid-input': roomCreateValidation.minAge }" />
<span v-if="roomCreateValidation.minAge" class="room-create-error">{{ roomCreateValidation.minAge }}</span>
</label>
<label>
{{ $t('chat.multichat.createRoom.labels.maxAge') }}
<input v-model.number="roomCreateForm.maxAge" type="number" min="0"
:class="{ 'invalid-input': roomCreateValidation.maxAge }" />
<span v-if="roomCreateValidation.maxAge" class="room-create-error">{{ roomCreateValidation.maxAge }}</span>
</label>
<label>
{{ $t('chat.multichat.createRoom.labels.password') }}
<input v-model.trim="roomCreateForm.password" type="text" :placeholder="$t('chat.multichat.createRoom.placeholders.password')"
:class="{ 'invalid-input': roomCreateValidation.password }" />
<span v-if="roomCreateValidation.password" class="room-create-error">{{ roomCreateValidation.password }}</span>
</label>
<label>
{{ $t('chat.multichat.createRoom.labels.rightId') }}
<select v-model="roomCreateForm.rightId" :class="{ 'invalid-input': roomCreateValidation.rightId }">
<option :value="null">{{ $t('chat.multichat.createRoom.options.none') }}</option>
<option v-for="right in roomCreateRights" :key="`right-${right.id}`" :value="right.id">
{{ getUserRightLabel(right) }}
</option>
</select>
<span v-if="roomCreateValidation.rightId" class="room-create-error">{{ roomCreateValidation.rightId }}</span>
</label>
<label>
{{ $t('chat.multichat.createRoom.labels.typeId') }}
<select v-model="roomCreateForm.typeId" :class="{ 'invalid-input': roomCreateValidation.typeId }">
<option :value="null">{{ $t('chat.multichat.createRoom.options.none') }}</option>
<option v-for="roomType in roomCreateTypes" :key="`type-${roomType.id}`" :value="roomType.id">
{{ getRoomTypeLabel(roomType) }}
</option>
</select>
<span v-if="roomCreateValidation.typeId" class="room-create-error">{{ roomCreateValidation.typeId }}</span>
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="roomCreateForm.friendsOnly" />
{{ $t('chat.multichat.createRoom.labels.friendsOnly') }}
</label>
</div>
<div class="room-create-actions">
<button type="button" class="send-btn" @click="sendCreateRoomCommand" :disabled="!canSendRoomCreate">{{ $t('chat.multichat.createRoom.actions.create') }}</button>
<button type="button" class="create-room-reset-btn" @click="resetRoomCreateForm">{{ $t('chat.multichat.createRoom.actions.reset') }}</button>
</div>
<div v-if="roomCreateValidation.range" class="room-create-error room-create-error-block">{{ roomCreateValidation.range }}</div>
<div class="room-create-preview">
{{ $t('chat.multichat.createRoom.commandPrefix') }}: <code>{{ buildRoomCreateCommandPreview() || '/cr <raumname>' }}</code>
</div>
<div class="owned-rooms-section">
<div class="owned-rooms-title">{{ $t('chat.multichat.createRoom.ownedRooms.title') }}</div>
<div class="owned-rooms-hint">{{ $t('chat.multichat.createRoom.ownedRooms.hint') }}</div>
<div v-if="!ownRooms.length" class="owned-rooms-empty">
{{ $t('chat.multichat.createRoom.ownedRooms.empty') }}
</div>
<div v-else class="owned-rooms-list">
<div v-for="room in ownRooms" :key="`own-room-${room.id}`" class="owned-room-item">
<div class="owned-room-main">
<span class="owned-room-name">{{ room.title }}</span>
<span class="owned-room-badge" :class="room.isPublic ? 'public' : 'private'">
{{ room.isPublic ? $t('chat.multichat.createRoom.ownedRooms.public') : $t('chat.multichat.createRoom.ownedRooms.private') }}
</span>
</div>
<button type="button" class="owned-room-delete-btn" @click="deleteOwnedRoom(room)">
{{ $t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
<div class="user-list">
<div class="user-list-header">Teilnehmer ({{ usersInRoom.length }})</div>
<div class="user-list-items">
@@ -69,6 +172,23 @@
</div>
</div>
</div>
<div v-if="!showColorPicker && passwordPromptVisible" class="room-password-panel">
<div class="room-password-title">{{ $t('chat.multichat.password.title') }}</div>
<div class="room-password-message">
{{ passwordPromptInvalid ? $t('chat.multichat.password.invalidPrompt', { room: passwordPromptRoom }) : $t('chat.multichat.password.requiredPrompt', { room: passwordPromptRoom }) }}
</div>
<div class="room-password-controls">
<input
v-model="passwordPromptValue"
class="room-password-input"
type="password"
:placeholder="$t('chat.multichat.password.inputLabel')"
@keyup.enter="submitRoomPassword"
/>
<button type="button" class="send-btn" @click="submitRoomPassword">{{ $t('chat.multichat.password.submit') }}</button>
<button type="button" class="create-room-reset-btn" @click="cancelRoomPassword">{{ $t('chat.multichat.password.cancel') }}</button>
</div>
</div>
<div v-if="!showColorPicker" class="multi-chat-input">
<input v-model="input" @keyup.enter="sendMessage" class="chat-input"
:placeholder="$t('chat.multichat.placeholder')" />
@@ -116,7 +236,7 @@
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import { fetchPublicRooms } from '@/api/chatApi.js';
import { fetchPublicRooms, fetchRoomCreateOptions, fetchOwnRooms } from '@/api/chatApi.js';
import { mapGetters } from 'vuex';
import { getChatWsUrl, getChatWsCandidates, getChatWsProtocols } from '@/services/chatWs.js';
@@ -130,6 +250,30 @@ export default {
try {
return !!(this.menu && this.menu.administration);
} catch (_) { return false; }
},
roomCreateValidation() {
const errors = {};
const name = (this.roomCreateForm.roomName || '').trim();
const minAge = this.parseOptionalInteger(this.roomCreateForm.minAge);
const maxAge = this.parseOptionalInteger(this.roomCreateForm.maxAge);
const rightId = this.parseOptionalInteger(this.roomCreateForm.rightId);
const typeId = this.parseOptionalInteger(this.roomCreateForm.typeId);
const password = this.roomCreateForm.password || '';
if (!name) errors.roomName = this.$t('chat.multichat.createRoom.validation.roomNameRequired');
if (minAge !== null && minAge < 0) errors.minAge = this.$t('chat.multichat.createRoom.validation.minAgeInvalid');
if (maxAge !== null && maxAge < 0) errors.maxAge = this.$t('chat.multichat.createRoom.validation.maxAgeInvalid');
if (minAge !== null && maxAge !== null && minAge > maxAge) {
errors.range = this.$t('chat.multichat.createRoom.validation.ageRangeInvalid');
}
if (password.includes(' ')) errors.password = this.$t('chat.multichat.createRoom.validation.passwordSpaces');
if (rightId !== null && rightId <= 0) errors.rightId = this.$t('chat.multichat.createRoom.validation.rightIdInvalid');
if (typeId !== null && typeId <= 0) errors.typeId = this.$t('chat.multichat.createRoom.validation.typeIdInvalid');
return errors;
},
canSendRoomCreate() {
return Object.keys(this.roomCreateValidation).length === 0;
}
},
mounted() {
@@ -140,6 +284,10 @@ export default {
beforeUnmount() {
// Safety: ensure connection is shut down on page/navigation leave
this.opened = false;
if (this.pendingRoomCreateTimer) {
clearTimeout(this.pendingRoomCreateTimer);
this.pendingRoomCreateTimer = null;
}
this.disconnectChatSocket();
try { window.removeEventListener('online', this.onOnline); } catch (_) { }
try { document.removeEventListener('click', this.onGlobalClick); } catch (_) { }
@@ -168,10 +316,33 @@ export default {
token: null,
announcedRoomEnter: false,
showColorPicker: false,
showRoomCreatePanel: false,
selectedColor: '#000000',
lastColor: '#000000',
hexInput: '#000000',
hexInvalid: false,
roomCreateForm: {
roomName: '',
visibility: '',
gender: '',
minAge: null,
maxAge: null,
password: '',
friendsOnly: false,
rightId: null,
typeId: null
},
roomCreateRights: [],
roomCreateTypes: [],
ownRooms: [],
pendingRoomCreateName: '',
pendingRoomCreateAttempts: 0,
pendingRoomCreateTimer: null,
roomPasswords: {},
passwordPromptVisible: false,
passwordPromptInvalid: false,
passwordPromptRoom: '',
passwordPromptValue: '',
// Palette state
paletteWidth: 420,
paletteHeight: 220,
@@ -201,8 +372,8 @@ export default {
happyDelayMs: 40,
// Join fallback delay if token is slow to arrive
joinFallbackDelayMs: 120,
// Limit how many parallel WS candidates to race (prevents server socket buildup)
raceLimit: 3
// Default: only one connection attempt in parallel (prevents duplicate daemon sockets)
raceLimit: 1
};
},
// Hinweis: Öffnen erfolgt über methods.open(), damit Parent per Ref aufrufen kann
@@ -216,14 +387,280 @@ export default {
selectedRoom(newVal, oldVal) {
if (newVal && this.transportConnected) {
const room = this.getSelectedRoomName();
if (room) this.sendWithToken({ type: 'join', room, name: this.user?.username || '' });
if (room) this.sendWithToken({ type: 'join', room, password: this.getRoomPassword(room) });
this.messages = [];
this.usersInRoom = [];
this.selectedTargetUser = null;
this.passwordPromptVisible = false;
this.passwordPromptInvalid = false;
this.passwordPromptRoom = '';
this.passwordPromptValue = '';
}
}
},
methods: {
getDefaultRoomCreateForm() {
return {
roomName: '',
visibility: '',
gender: '',
minAge: null,
maxAge: null,
password: '',
friendsOnly: false,
rightId: null,
typeId: null
};
},
toggleRoomCreatePanel() {
this.showRoomCreatePanel = !this.showRoomCreatePanel;
if (!this.showRoomCreatePanel) return;
this.showColorPicker = false;
},
resetRoomCreateForm() {
this.roomCreateForm = this.getDefaultRoomCreateForm();
},
clearPendingRoomCreateTracking() {
if (this.pendingRoomCreateTimer) {
clearTimeout(this.pendingRoomCreateTimer);
this.pendingRoomCreateTimer = null;
}
this.pendingRoomCreateName = '';
this.pendingRoomCreateAttempts = 0;
},
roomNamesEqual(a, b) {
return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase();
},
getRoomPassword(roomName) {
if (!roomName) return '';
return this.roomPasswords[roomName] || '';
},
handleRoomPasswordError(errorCode) {
const room = this.getSelectedRoomName();
if (!room || this.passwordPromptVisible) return;
this.passwordPromptRoom = room;
this.passwordPromptInvalid = errorCode === 'room_password_invalid';
this.passwordPromptValue = '';
this.passwordPromptVisible = true;
},
submitRoomPassword() {
const room = this.passwordPromptRoom || this.getSelectedRoomName();
if (!room) {
this.passwordPromptVisible = false;
return;
}
const password = (this.passwordPromptValue || '').trim();
if (!password) {
this.messages.push({
id: Date.now(),
user: 'System',
text: this.$t('chat.multichat.password.empty')
});
return;
}
this.roomPasswords[room] = password;
this.passwordPromptVisible = false;
this.passwordPromptInvalid = false;
this.passwordPromptRoom = '';
this.passwordPromptValue = '';
this.sendWithToken({ type: 'join', room, password });
},
cancelRoomPassword() {
const room = this.passwordPromptRoom || this.getSelectedRoomName();
this.passwordPromptVisible = false;
this.passwordPromptInvalid = false;
this.passwordPromptRoom = '';
this.passwordPromptValue = '';
this.messages.push({
id: Date.now(),
user: 'System',
text: this.$t('chat.multichat.password.cancelled', { room })
});
},
tryConfirmRoomCreateSuccess() {
if (!this.pendingRoomCreateName) return false;
const created = this.ownRooms.find((r) => this.roomNamesEqual(r.title, this.pendingRoomCreateName));
if (!created) return false;
this.messages.push({
id: Date.now(),
user: 'System',
text: this.$t('chat.multichat.createRoom.messages.created', { room: created.title || this.pendingRoomCreateName })
});
this.clearPendingRoomCreateTracking();
return true;
},
scheduleRoomCreateConfirmationCheck() {
if (!this.pendingRoomCreateName) return;
if (this.pendingRoomCreateAttempts >= 6) {
this.messages.push({
id: Date.now(),
user: 'System',
text: this.$t('chat.multichat.createRoom.messages.createNotConfirmed', { room: this.pendingRoomCreateName })
});
this.clearPendingRoomCreateTracking();
return;
}
if (this.pendingRoomCreateTimer) {
clearTimeout(this.pendingRoomCreateTimer);
this.pendingRoomCreateTimer = null;
}
this.pendingRoomCreateTimer = setTimeout(async () => {
this.pendingRoomCreateAttempts += 1;
await this.loadOwnRooms();
if (!this.tryConfirmRoomCreateSuccess()) {
this.scheduleRoomCreateConfirmationCheck();
}
}, 1200);
},
buildRoomCreateCommandPreview() {
return this.buildRoomCreateCommand();
},
getUserRightLabel(right) {
const raw = (right?.title || '').trim();
if (!raw) return right?.id ?? '';
const candidates = [
`chat.multichat.createRoom.rights.${raw}`,
`navigation.${raw}`,
`navigation.m-administration.${raw}`
];
for (const key of candidates) {
if (this.$te(key)) return this.$t(key);
}
return raw
.replace(/_/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/\s+/g, ' ')
.trim();
},
getRoomTypeLabel(roomType) {
const raw = (roomType?.name || '').trim();
if (!raw) return roomType?.id ?? '';
const key = `chat.multichat.createRoom.types.${raw}`;
return this.$te(key) ? this.$t(key) : raw;
},
async loadRoomCreateOptions() {
try {
const options = await fetchRoomCreateOptions();
this.roomCreateRights = Array.isArray(options?.rights) ? options.rights : [];
this.roomCreateTypes = Array.isArray(options?.roomTypes) ? options.roomTypes : [];
} catch (e) {
console.error('Failed loading room create options', e);
this.roomCreateRights = [];
this.roomCreateTypes = [];
}
},
async loadOwnRooms() {
try {
const rooms = await fetchOwnRooms();
this.ownRooms = Array.isArray(rooms) ? rooms : [];
this.tryConfirmRoomCreateSuccess();
} catch (e) {
console.error('Failed loading own rooms', e);
this.ownRooms = [];
}
},
async deleteOwnedRoom(room) {
const title = room?.title || '';
const confirmed = window.confirm(this.$t('chat.multichat.createRoom.ownedRooms.confirmDelete', { room: title }));
if (!confirmed) return;
if (!this.transportConnected) {
this.messages.push({
id: Date.now(),
user: 'System',
text: this.$t('chat.multichat.createRoom.messages.noConnection')
});
return;
}
try {
const payload = { type: 'message', message: `/dr ${title}` };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
this.messages.push({
id: Date.now(),
user: 'System',
text: this.$t('chat.multichat.createRoom.ownedRooms.deleteSent', { room: title })
});
this.requestRoomRefreshAfterCreate();
setTimeout(() => this.loadOwnRooms(), 700);
setTimeout(() => this.loadOwnRooms(), 2000);
} catch (e) {
console.error('Failed deleting own room', e);
this.messages.push({
id: Date.now(),
user: 'System',
text: this.$t('chat.multichat.createRoom.ownedRooms.deleteError')
});
}
},
parseOptionalInteger(value) {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
if (!Number.isFinite(num)) return null;
return Math.trunc(num);
},
buildRoomCreateCommand() {
const name = (this.roomCreateForm.roomName || '').trim();
if (!name) return '';
const parts = ['/cr', name];
if (this.roomCreateForm.visibility === 'public') {
parts.push('public', 'public=true');
} else if (this.roomCreateForm.visibility === 'private') {
parts.push('private', 'public=false');
}
if (this.roomCreateForm.gender) parts.push(`gender=${this.roomCreateForm.gender}`);
const minAge = this.parseOptionalInteger(this.roomCreateForm.minAge);
if (minAge !== null && minAge >= 0) {
parts.push(`min_age=${minAge}`);
}
const maxAge = this.parseOptionalInteger(this.roomCreateForm.maxAge);
if (maxAge !== null && maxAge >= 0) {
parts.push(`max_age=${maxAge}`);
}
const password = (this.roomCreateForm.password || '').trim();
if (password) parts.push(`password=${password}`);
if (this.roomCreateForm.friendsOnly) parts.push('friends_only=true');
const rightId = this.parseOptionalInteger(this.roomCreateForm.rightId);
if (rightId !== null && rightId > 0) {
parts.push(`right_id=${rightId}`);
}
const typeId = this.parseOptionalInteger(this.roomCreateForm.typeId);
if (typeId !== null && typeId > 0) {
parts.push(`type_id=${typeId}`);
}
return parts.join(' ');
},
requestRoomRefreshAfterCreate() {
const requestRooms = () => {
if (this.isAdmin) this.sendWithToken({ type: 'reload_rooms' });
this.sendWithToken({ type: 'rooms' });
};
setTimeout(requestRooms, 500);
setTimeout(requestRooms, 1800);
},
sendCreateRoomCommand() {
if (!this.transportConnected) {
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.noConnection') });
return;
}
if (!this.canSendRoomCreate) {
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.invalidForm') });
return;
}
const command = this.buildRoomCreateCommand();
if (!command) {
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.roomNameMissing') });
return;
}
const payload = { type: 'message', message: command };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
this.messages.push({ id: Date.now(), user: 'System', text: this.$t('chat.multichat.createRoom.messages.sent', { command }) });
this.clearPendingRoomCreateTracking();
this.pendingRoomCreateName = (this.roomCreateForm.roomName || '').trim();
this.pendingRoomCreateAttempts = 0;
this.requestRoomRefreshAfterCreate();
this.scheduleRoomCreateConfirmationCheck();
},
selectTargetUser(name) {
if (this.selectedTargetUser === name) {
this.selectedTargetUser = null; // toggle off
@@ -260,6 +697,7 @@ export default {
onDialogClose() {
// Mark as closed first so any async close events won't schedule reconnect
this.opened = false;
this.clearPendingRoomCreateTracking();
console.log('[Chat WS] dialog close — closing websocket');
this.disconnectChatSocket();
// Remove network event listeners
@@ -301,14 +739,9 @@ export default {
async reloadRoomsAdmin() {
if (!this.isAdmin) return;
try {
const current = this.selectedRoom;
const data = await fetchPublicRooms();
const rooms = Array.isArray(data) ? data : [];
this.rooms = rooms;
if (!rooms.find(r => r.id === current)) {
this.selectedRoom = rooms.length ? rooms[0].id : null;
}
this.messages.push({ id: Date.now(), user: 'System', text: 'Raumliste aktualisiert.' });
this.sendWithToken({ type: 'reload_rooms' });
this.sendWithToken({ type: 'rooms' });
this.messages.push({ id: Date.now(), user: 'System', text: 'Raum-Reload angefordert.' });
} catch (e) {
console.error('Fehler beim Neuladen der Räume', e);
this.messages.push({ id: Date.now(), user: 'System', text: 'Fehler beim Neuladen der Raumliste.' });
@@ -324,6 +757,9 @@ export default {
this.selectedTargetUser = null;
this.input = '';
this.showOptions = false;
this.showRoomCreatePanel = false;
this.loadRoomCreateOptions();
this.loadOwnRooms();
this.announcedRoomEnter = false;
this.$refs.dialog.open();
// Stelle die WS-Verbindung her, wenn der Dialog geöffnet wird
@@ -394,12 +830,7 @@ export default {
});
},
getRaceLimit() {
try {
const ls = localStorage.getItem('chatWsRaceMax');
const n = parseInt(ls, 10);
if (!isNaN(n) && n > 0) return Math.min(n, 6);
} catch (_) { }
return this.raceLimit || 3;
return 1;
},
spawnCandidate(url, protocols) {
if (!this.opened) return;
@@ -446,7 +877,8 @@ export default {
// Drop references to losers so GC can collect
this.pendingWs = [];
// Prepare handshake like before
const init = { type: 'init', name: this.user?.username || '', room: this.getSelectedRoomName() || '' };
const initRoom = this.getSelectedRoomName() || '';
const init = { type: 'init', name: this.user?.username || '', room: initRoom, password: this.getRoomPassword(initRoom) };
if (this.debug) console.log('[Chat WS >>]', init);
this.wsSend(init);
if (this.connectAttemptTimeout) clearTimeout(this.connectAttemptTimeout);
@@ -543,7 +975,8 @@ export default {
this.transportConnected = true;
const dt = Date.now() - (this.wsStartAt || Date.now());
console.log('[Chat WS] open in', dt, 'ms', '| protocol:', ws.protocol || '(none)', '| url:', url);
const init = { type: 'init', name: this.user?.username || '', room: this.getSelectedRoomName() || '' };
const initRoom = this.getSelectedRoomName() || '';
const init = { type: 'init', name: this.user?.username || '', room: initRoom, password: this.getRoomPassword(initRoom) };
if (this.debug) console.log('[Chat WS >>]', init);
this.wsSend(init);
if (this.connectOpenTimer) { clearTimeout(this.connectOpenTimer); this.connectOpenTimer = null; }
@@ -661,9 +1094,8 @@ export default {
}
try {
// Send a ping message to keep connection alive
this.wsSend({ type: 'ping' });
console.log('[Chat WS] Heartbeat sent');
// Keepalive via supported protocol command
if (this.token) this.wsSend({ type: 'userlist', token: this.token });
} catch (error) {
console.warn('[Chat WS] Heartbeat failed:', error);
this.stopHeartbeat();
@@ -823,7 +1255,7 @@ export default {
action() {
if (!this.input.trim()) return;
if (!this.selectedTargetUser) return; // Nur mit Auswahl
const payload = { type: 'do', value: this.input, to: this.selectedTargetUser };
const payload = { type: 'do', message: `${this.input} ${this.selectedTargetUser}`.trim() };
if (this.debug) console.log('[Chat WS >>]', payload);
this.sendWithToken(payload);
this.input = '';
@@ -1053,6 +1485,16 @@ export default {
},
onWsObject(obj) {
if (!obj) return;
if (obj.type === 'error') {
const text = obj.message || 'chat_error';
if (text === 'room_password_required' || text === 'room_password_invalid') {
this.handleRoomPasswordError(text);
return;
}
this.setStatus('error');
this.messages.push({ id: Date.now(), user: 'System', text: `Fehler: ${text}` });
return;
}
// Token handshake: only when explicitly marked as token-type
if (obj.type === 'token' || obj.type === 1) {
let tok = obj.token;
@@ -1062,6 +1504,8 @@ export default {
}
if (tok) {
this.token = tok;
this.sendWithToken({ type: 'rooms' });
this.sendWithToken({ type: 'userlist' });
// No extra join here; we already sent init with room
if (this.joinFallbackTimer) { clearTimeout(this.joinFallbackTimer); this.joinFallbackTimer = null; }
this.flushPending();
@@ -1136,11 +1580,33 @@ export default {
return;
}
if (obj.type === 3 && Array.isArray(obj.message)) {
const names = obj.message.map(r => r.name).filter(Boolean).join(', ');
this.handleIncoming({ type: 'system', text: names ? `Rooms: ${names}` : 'Rooms updated' });
const mapped = obj.message
.filter(r => r && r.name)
.map((r, index) => ({
id: r.name || index + 1,
title: r.name,
name: r.name,
users: Number(r.users || 0)
}));
if (mapped.length > 0) {
const currentName = this.getSelectedRoomName();
this.rooms = mapped;
if (currentName) {
const existing = mapped.find(r => r.name === currentName);
this.selectedRoom = existing ? existing.id : mapped[0].id;
} else if (!this.selectedRoom) {
this.selectedRoom = mapped[0].id;
}
}
return;
}
if (obj.type === 5) {
if (obj.message === 'room_entered') {
const to = obj.to || obj.room || this.getSelectedRoomName();
this.handleIncoming({ type: 'system', code: 'room_entered', tr: 'room_entered', to });
this.sendWithToken({ type: 'userlist' });
return;
}
const msg = obj.message;
if (typeof msg === 'string') {
// Some servers send a JSON-encoded string here; parse if it looks like JSON
@@ -1457,10 +1923,24 @@ export default {
justify-content: space-between;
}
.room-left-controls {
display: flex;
align-items: center;
gap: 0.5em;
}
.room-select {
min-width: 10em;
}
.create-room-toggle-btn {
border: 1px solid #bbb;
background: #f5f5f5;
border-radius: 4px;
padding: 0.3em 0.8em;
cursor: pointer;
}
.right-controls {
display: flex;
align-items: center;
@@ -1567,6 +2047,188 @@ export default {
border: 1px solid #222;
}
.room-create-panel {
border: 1px solid #222;
border-radius: 4px;
background: #fff;
padding: 0.7em;
overflow-y: auto;
}
.room-create-title {
font-weight: bold;
margin-bottom: 0.6em;
}
.room-create-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6em;
}
.room-create-grid label {
display: flex;
flex-direction: column;
gap: 0.25em;
}
.room-create-grid input,
.room-create-grid select {
border: 1px solid #bbb;
border-radius: 3px;
padding: 0.35em 0.5em;
}
.invalid-input {
border-color: #c62828 !important;
background: #fff6f6;
}
.checkbox-label {
flex-direction: row !important;
align-items: center;
gap: 0.4em !important;
}
.room-create-actions {
display: flex;
gap: 0.5em;
margin-top: 0.8em;
}
.create-room-reset-btn {
border: 1px solid #bbb;
background: #eee;
border-radius: 3px;
padding: 0.3em 0.8em;
cursor: pointer;
}
.room-create-preview {
margin-top: 0.7em;
font-size: 0.9em;
color: #444;
}
.owned-rooms-section {
margin-top: 0.9em;
border-top: 1px solid #eee;
padding-top: 0.7em;
}
.owned-rooms-title {
font-weight: bold;
margin-bottom: 0.4em;
}
.owned-rooms-hint {
font-size: 0.85em;
color: #666;
margin-bottom: 0.45em;
}
.owned-rooms-empty {
font-size: 0.9em;
color: #666;
}
.owned-rooms-list {
display: flex;
flex-direction: column;
gap: 0.35em;
}
.owned-room-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6em;
border: 1px solid #eee;
border-radius: 4px;
padding: 0.35em 0.5em;
}
.owned-room-main {
display: flex;
align-items: center;
gap: 0.5em;
min-width: 0;
}
.owned-room-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.owned-room-badge {
font-size: 0.75em;
border-radius: 999px;
padding: 0.1em 0.5em;
border: 1px solid #bbb;
}
.owned-room-badge.public {
background: #e8f5e9;
border-color: #81c784;
}
.owned-room-badge.private {
background: #fff3e0;
border-color: #ffb74d;
}
.owned-room-delete-btn {
border: 1px solid #bbb;
border-radius: 3px;
background: #fff;
cursor: pointer;
padding: 0.2em 0.55em;
}
.room-create-error {
color: #b00020;
font-size: 0.85em;
}
.room-create-error-block {
margin-top: 0.5em;
}
.room-password-panel {
margin-top: 0.6em;
margin-bottom: 0.6em;
padding: 0.6em;
border: 1px solid #d7d7d7;
border-radius: 6px;
background: #f8f9fb;
}
.room-password-title {
font-weight: bold;
margin-bottom: 0.25em;
}
.room-password-message {
font-size: 0.9em;
color: #444;
margin-bottom: 0.45em;
}
.room-password-controls {
display: flex;
align-items: center;
gap: 0.45em;
}
.room-password-input {
flex: 1;
min-width: 0;
border: 1px solid #bbb;
border-radius: 3px;
padding: 0.35em 0.5em;
}
.chat-message {
margin-bottom: 0.3em;
}

View File

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

View File

@@ -1,7 +1,8 @@
<template>
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="25em"
height="15em" name="ErrorDialog" :isTitleTranslated=true>
<DialogWidget ref="dialog" title="error.title" :show-close="true" :buttons="buttons" :modal="true" width="28em"
height="16em" name="ErrorDialog" :isTitleTranslated=true>
<div class="error-content">
<span class="error-content__badge">Fehler</span>
<p>{{ translatedErrorMessage }}</p>
</div>
</DialogWidget>
@@ -45,8 +46,27 @@ export default {
<style scoped>
.error-content {
padding: 1em;
color: red;
display: grid;
gap: 12px;
padding: 1.2em;
text-align: center;
}
.error-content__badge {
justify-self: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(177, 59, 53, 0.12);
color: var(--color-danger);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.error-content p {
margin: 0;
color: var(--color-text-primary);
line-height: 1.55;
}
</style>

View File

@@ -1,7 +1,8 @@
<template>
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="25em"
height="15em" name="MessageDialog" :isTitleTranslated=false>
<DialogWidget ref="dialog" :title="translatedTitle" :show-close="true" :buttons="translatedButtons" :modal="true" width="28em"
height="16em" name="MessageDialog" :isTitleTranslated=false>
<div class="message-content">
<span class="message-content__badge">Hinweis</span>
<p>{{ translatedMessage }}</p>
</div>
</DialogWidget>
@@ -41,14 +42,6 @@ export default {
if (this.message.startsWith('tr:')) {
const i18nKey = this.message.substring(3);
const translation = this.$t(i18nKey);
console.log('translatedMessage:', {
i18nKey: i18nKey,
translation: translation,
parameters: this.parameters,
allMinigames: this.$t('minigames'),
crashSection: this.$t('minigames.taxi.crash')
});
// Ersetze Parameter in der Übersetzung
return this.interpolateParameters(translation);
}
return this.message;
@@ -89,26 +82,16 @@ export default {
}
},
interpolateParameters(text) {
// Ersetze {key} Platzhalter mit den entsprechenden Werten
let result = text;
console.log('interpolateParameters:', {
originalText: text,
parameters: this.parameters
});
for (const [key, value] of Object.entries(this.parameters)) {
const placeholder = `{${key}}`;
const regex = new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g');
result = result.replace(regex, value);
console.log(`Replaced ${placeholder} with ${value}:`, result);
}
console.log('Final result:', result);
return result;
}
},
beforeDestroy() {
// Stelle sicher, dass Event Listener entfernt wird
beforeUnmount() {
document.removeEventListener('keydown', this.handleKeyDown);
}
};
@@ -116,8 +99,27 @@ export default {
<style scoped>
.message-content {
padding: 1em;
color: #000000;
display: grid;
gap: 12px;
padding: 1.2em;
text-align: center;
}
.message-content__badge {
justify-self: center;
padding: 4px 10px;
border-radius: 999px;
background: rgba(120, 195, 138, 0.16);
color: #24523a;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.message-content p {
margin: 0;
color: var(--color-text-primary);
line-height: 1.55;
}
</style>

View File

@@ -39,6 +39,25 @@ import deMinigames from './locales/de/minigames.json';
import deMessage from './locales/de/message.json';
import dePersonal from './locales/de/personal.json';
import esGeneral from './locales/es/general.json';
import esHeader from './locales/es/header.json';
import esNavigation from './locales/es/navigation.json';
import esHome from './locales/es/home.json';
import esChat from './locales/es/chat.json';
import esRegister from './locales/es/register.json';
import esError from './locales/es/error.json';
import esActivate from './locales/es/activate.json';
import esSettings from './locales/es/settings.json';
import esAdmin from './locales/es/admin.json';
import esSocialNetwork from './locales/es/socialnetwork.json';
import esFriends from './locales/es/friends.json';
import esFalukant from './locales/es/falukant.json';
import esPasswordReset from './locales/es/passwordReset.json';
import esBlog from './locales/es/blog.json';
import esMinigames from './locales/es/minigames.json';
import esMessage from './locales/es/message.json';
import esPersonal from './locales/es/personal.json';
const messages = {
en: {
...enGeneral,
@@ -80,6 +99,26 @@ const messages = {
...deMinigames,
...deMessage,
...dePersonal,
},
es: {
...esGeneral,
...esHeader,
...esNavigation,
...esHome,
...esChat,
...esRegister,
...esPasswordReset,
...esError,
...esActivate,
...esSettings,
...esAdmin,
...esSocialNetwork,
...esFriends,
...esFalukant,
...esBlog,
...esMinigames,
...esMessage,
...esPersonal,
}
};

View File

@@ -38,6 +38,92 @@
"connected": "Verbunden",
"disconnected": "Getrennt",
"error": "Fehler bei der Verbindung"
},
"reloadRooms": "Räume neu laden",
"createRoom": {
"toggleShowChat": "Chat anzeigen",
"toggleCreateRoom": "Raum anlegen",
"title": "Neuen Raum erstellen",
"commandPrefix": "Kommando",
"labels": {
"roomName": "Raumname",
"visibility": "Sichtbarkeit",
"gender": "Geschlecht",
"minAge": "Mindestalter",
"maxAge": "Höchstalter",
"password": "Passwort",
"rightId": "Benötigtes Recht",
"typeId": "Raumtyp",
"friendsOnly": "friends_only=true"
},
"placeholders": {
"roomName": "z. B. Lounge",
"password": "ohne Leerzeichen"
},
"options": {
"none": "(keine)",
"visibilityPublic": "Öffentlich",
"visibilityPrivate": "Privat",
"genderMale": "Männlich",
"genderFemale": "Weiblich",
"genderAny": "Alle / Keine Einschränkung"
},
"actions": {
"create": "Raum erstellen",
"reset": "Zurücksetzen"
},
"validation": {
"roomNameRequired": "Raumname ist erforderlich.",
"minAgeInvalid": "min_age muss >= 0 sein.",
"maxAgeInvalid": "max_age muss >= 0 sein.",
"ageRangeInvalid": "min_age darf nicht größer als max_age sein.",
"passwordSpaces": "Passwort darf keine Leerzeichen enthalten.",
"rightIdInvalid": "right_id muss > 0 sein.",
"typeIdInvalid": "type_id muss > 0 sein."
},
"messages": {
"noConnection": "Keine Verbindung zum Chat-Server.",
"invalidForm": "Bitte Eingaben im Raum-Formular korrigieren.",
"roomNameMissing": "Bitte einen Raumnamen angeben.",
"sent": "Raum-Erstellung gesendet: {command}",
"created": "Raum \"{room}\" wurde erfolgreich erstellt.",
"createNotConfirmed": "Raum \"{room}\" wurde noch nicht bestätigt. Bitte Raumliste prüfen."
},
"ownedRooms": {
"title": "Meine erstellten Räume",
"hint": "Löschen per Daemon-Befehl: /dr <raumname> (Alias: /delete_room <raumname>)",
"empty": "Du hast noch keine eigenen Räume.",
"public": "public",
"private": "private",
"confirmDelete": "Soll der Raum \"{room}\" wirklich gelöscht werden?",
"deleteSent": "Löschbefehl gesendet: /dr {room}",
"deleteError": "Raum konnte nicht gelöscht werden."
},
"rights": {
"mainadmin": "Hauptadministrator",
"contactrequests": "Kontaktanfragen",
"users": "Benutzer",
"userrights": "Benutzerrechte",
"forum": "Forum",
"interests": "Interessen",
"falukant": "Falukant",
"minigames": "Minispiele",
"match3": "Match3",
"taxiTools": "Taxi-Tools",
"chatrooms": "Chaträume",
"servicesStatus": "Service-Status"
},
"types": {}
},
"password": {
"title": "Passwort erforderlich",
"inputLabel": "Passwort eingeben",
"submit": "Beitreten",
"cancel": "Abbrechen",
"requiredPrompt": "Der Raum \"{room}\" ist passwortgeschützt. Bitte Passwort eingeben:",
"invalidPrompt": "Falsches Passwort für \"{room}\". Bitte erneut eingeben:",
"cancelled": "Beitritt zu \"{room}\" abgebrochen.",
"empty": "Passwort darf nicht leer sein."
}
},
"randomchat": {

View File

@@ -114,12 +114,21 @@
},
"overview": {
"title": "Falukant - Übersicht",
"heirSelection": {
"title": "Erben-Auswahl",
"description": "Dein bisheriger Charakter ist nicht mehr verfügbar. Wähle einen Erben aus der Liste, um mit diesem weiterzuspielen.",
"loading": "Lade mögliche Erben…",
"noHeirs": "Keine Erben verfügbar.",
"select": "Als Spielcharakter wählen",
"error": "Fehler beim Auswählen des Erben."
},
"metadata": {
"title": "Persönliches",
"name": "Name",
"money": "Vermögen",
"age": "Alter",
"years": "Jahre",
"days": "Tage",
"mainbranch": "Heimatstadt",
"nobleTitle": "Stand"
},
@@ -334,6 +343,9 @@
"current": "Laufende Produktionen",
"product": "Produkt",
"remainingTime": "Verbleibende Zeit (Sekunden)",
"status": "Status",
"sleep": "Pausiert",
"active": "Aktiv",
"noProductions": "Keine laufenden Produktionen."
},
"columns": {
@@ -485,6 +497,7 @@
"gifts": "Werbegeschenke",
"sendGift": "Werbegeschenk senden",
"cancel": "Werbung abbrechen",
"cancelConfirm": "Willst du die Werbung wirklich abbrechen? Der Fortschritt geht dabei verloren.",
"cancelSuccess": "Die Werbung wurde abgebrochen.",
"cancelError": "Die Werbung konnte nicht abgebrochen werden.",
"cancelTooSoon": "Du kannst die Werbung erst nach 24 Stunden abbrechen.",
@@ -605,6 +618,23 @@
"time": "Zeit",
"prev": "Zurück",
"next": "Weiter",
"graph": {
"open": "Verlauf anzeigen",
"title": "Geldentwicklung",
"close": "Schließen",
"loading": "Lade Verlauf...",
"noData": "Für den gewählten Zeitraum liegen keine Buchungen vor.",
"yesterday": "Gestern",
"range": {
"label": "Zeitraum",
"today": "Heute",
"24h": "Letzte 24 Stunden",
"week": "Letzte Woche",
"month": "Letzter Monat",
"year": "Letztes Jahr",
"all": "Gesamter Verlauf"
}
},
"activities": {
"Product sale": "Produkte verkauft",
"Production cost": "Produktionskosten",
@@ -675,6 +705,7 @@
"happy": "Glücklich",
"sad": "Traurig",
"angry": "Wütend",
"calm": "Ruhig",
"nervous": "Nervös",
"excited": "Aufgeregt",
"bored": "Gelangweilt",
@@ -765,17 +796,39 @@
"advance": {
"confirm": "Aufsteigen beantragen"
},
"cooldown": "Du kannst frühestens wieder am {date} aufsteigen.",
"errors": {
"tooSoon": "Aufstieg zu früh.",
"unmet": "Folgende Voraussetzungen fehlen:",
"generic": "Der Aufstieg ist fehlgeschlagen."
}
"cooldown": "Du kannst frühestens wieder am {date} aufsteigen."
},
"reputation": {
"title": "Reputation",
"overview": {
"title": "Übersicht"
"title": "Übersicht",
"current": "Aktuelle Reputation"
},
"actions": {
"title": "Reputations-Aktionen",
"description": "Du kannst verschiedene Aktionen durchführen, um deine Reputation zu verbessern.",
"none": "Keine Reputations-Aktionen verfügbar.",
"action": "Aktion",
"cost": "Kosten",
"gain": "Gewinn",
"timesUsed": "Verwendet",
"execute": "Ausführen",
"running": "Läuft...",
"dailyLimit": "Tägliches Limit: {remaining} von {cap} Aktionen übrig",
"cooldown": "Cooldown: Noch {minutes} Minuten",
"type": {
"soup_kitchen": "Suppenküche",
"library_donation": "Bibliotheksspende",
"scholarships": "Stipendien",
"church_hospice": "Kirchenhospiz",
"school_funding": "Schulfinanzierung",
"orphanage_build": "Waisenhaus bauen",
"bridge_build": "Brücke bauen",
"hospital_donation": "Krankenhausspende",
"patronage": "Mäzenatentum",
"statue_build": "Statue errichten",
"well_build": "Brunnen bauen"
}
},
"party": {
"title": "Feste",
@@ -826,6 +879,53 @@
},
"church": {
"title": "Kirche",
"tabs": {
"current": "Aktuelle Positionen",
"available": "Verfügbare Positionen",
"applications": "Bewerbungen"
},
"current": {
"office": "Amt",
"region": "Region",
"holder": "Inhaber",
"supervisor": "Vorgesetzter",
"none": "Keine aktuellen Positionen vorhanden."
},
"available": {
"office": "Amt",
"region": "Region",
"supervisor": "Vorgesetzter",
"seats": "Verfügbare Plätze",
"action": "Aktion",
"apply": "Bewerben",
"applySuccess": "Bewerbung erfolgreich eingereicht.",
"applyError": "Fehler beim Einreichen der Bewerbung.",
"none": "Keine verfügbaren Positionen."
},
"applications": {
"office": "Amt",
"region": "Region",
"applicant": "Bewerber",
"date": "Datum",
"action": "Aktion",
"approve": "Annehmen",
"reject": "Ablehnen",
"approveSuccess": "Bewerbung angenommen.",
"rejectSuccess": "Bewerbung abgelehnt.",
"decideError": "Fehler bei der Entscheidung.",
"none": "Keine Bewerbungen vorhanden."
},
"offices": {
"lay-preacher": "Laienprediger",
"village-priest": "Dorfgeistlicher",
"parish-priest": "Pfarrer",
"dean": "Dekan",
"archdeacon": "Erzdiakon",
"bishop": "Bischof",
"archbishop": "Erzbischof",
"cardinal": "Kardinal",
"pope": "Papst"
},
"baptism": {
"title": "Taufen",
"table": {
@@ -927,7 +1027,12 @@
"drunkOfLife": "Trunk des Lebens",
"barber": "Barbier"
},
"choose": "Bitte auswählen"
"choose": "Bitte auswählen",
"errors": {
"tooClose": "Du kannst nicht so oft Maßnahmen durchführen.",
"generic": "Ein Fehler ist aufgetreten."
},
"nextMeasureAt": "Nächste Maßnahme ab"
},
"politics": {
"title": "Politik",
@@ -951,9 +1056,13 @@
"region": "Region",
"date": "Datum",
"candidacy": "Kandidatur",
"candidacyWithAge": "Kandidatur (ab 16 Jahren)",
"none": "Keine offenen Positionen.",
"apply": "Für ausgewählte Positionen kandidieren"
"apply": "Für ausgewählte Positionen kandidieren",
"minAgeHint": "Kandidatur erst ab 16 Jahren möglich.",
"ageRequirement": "Für alle politischen Ämter gilt: Kandidatur erst ab 16 Jahren."
},
"too_young": "Dein Charakter ist noch zu jung. Eine Bewerbung ist erst ab 16 Jahren möglich.",
"upcoming": {
"office": "Amt",
"region": "Region",

View File

@@ -38,6 +38,92 @@
"connected": "Connected",
"disconnected": "Disconnected",
"error": "Connection error"
},
"reloadRooms": "Reload rooms",
"createRoom": {
"toggleShowChat": "Show chat",
"toggleCreateRoom": "Create room",
"title": "Create new room",
"commandPrefix": "Command",
"labels": {
"roomName": "Room name",
"visibility": "Visibility",
"gender": "Gender",
"minAge": "Minimum age",
"maxAge": "Maximum age",
"password": "Password",
"rightId": "Required right",
"typeId": "Room type",
"friendsOnly": "friends_only=true"
},
"placeholders": {
"roomName": "e.g. Lounge",
"password": "without spaces"
},
"options": {
"none": "(none)",
"visibilityPublic": "Public",
"visibilityPrivate": "Private",
"genderMale": "Male",
"genderFemale": "Female",
"genderAny": "Any / No restriction"
},
"actions": {
"create": "Create room",
"reset": "Reset"
},
"validation": {
"roomNameRequired": "Room name is required.",
"minAgeInvalid": "min_age must be >= 0.",
"maxAgeInvalid": "max_age must be >= 0.",
"ageRangeInvalid": "min_age must not be greater than max_age.",
"passwordSpaces": "Password must not contain spaces.",
"rightIdInvalid": "right_id must be > 0.",
"typeIdInvalid": "type_id must be > 0."
},
"messages": {
"noConnection": "No connection to chat server.",
"invalidForm": "Please correct the room form inputs.",
"roomNameMissing": "Please enter a room name.",
"sent": "Room creation sent: {command}",
"created": "Room \"{room}\" was created successfully.",
"createNotConfirmed": "Room \"{room}\" is not confirmed yet. Please check room list."
},
"ownedRooms": {
"title": "My created rooms",
"hint": "Delete via daemon command: /dr <roomname> (alias: /delete_room <roomname>)",
"empty": "You have no own rooms yet.",
"public": "public",
"private": "private",
"confirmDelete": "Do you really want to delete room \"{room}\"?",
"deleteSent": "Delete command sent: /dr {room}",
"deleteError": "Could not delete room."
},
"rights": {
"mainadmin": "Main administrator",
"contactrequests": "Contact requests",
"users": "Users",
"userrights": "User rights",
"forum": "Forum",
"interests": "Interests",
"falukant": "Falukant",
"minigames": "Mini games",
"match3": "Match3",
"taxiTools": "Taxi tools",
"chatrooms": "Chat rooms",
"servicesStatus": "Service status"
},
"types": {}
},
"password": {
"title": "Password required",
"inputLabel": "Enter password",
"submit": "Join room",
"cancel": "Cancel",
"requiredPrompt": "Room \"{room}\" is password-protected. Please enter password:",
"invalidPrompt": "Wrong password for \"{room}\". Please try again:",
"cancelled": "Join to \"{room}\" was cancelled.",
"empty": "Password must not be empty."
}
},
"randomchat": {

View File

@@ -94,29 +94,24 @@
"children_unbaptised": "Unbaptised children"
},
"overview": {
"metadata": {
"years": "years"
}
},
"genderAge": {
"ageGroups": "infant:2|toddler:5|child:13|maidenhood:20|adult:50|mature:70|elder:999",
"male": {
"infant": "babe",
"toddler": "wee one",
"child": "lad",
"maidenhood": "youth",
"adult": "man",
"mature": "goodman",
"elder": "old fellow"
"title": "Falukant - Overview",
"heirSelection": {
"title": "Heir Selection",
"description": "Your previous character is no longer available. Choose an heir from the list to continue playing.",
"loading": "Loading potential heirs…",
"noHeirs": "No heirs available.",
"select": "Select as play character",
"error": "Error selecting heir."
},
"female": {
"infant": "babe",
"toddler": "wee one",
"child": "lass",
"maidenhood": "maiden",
"adult": "woman",
"mature": "goodwife",
"elder": "old dame"
"metadata": {
"title": "Personal",
"name": "Name",
"money": "Wealth",
"age": "Age",
"years": "Years",
"days": "Days",
"mainbranch": "Home city",
"nobleTitle": "Title"
}
},
"health": {
@@ -137,6 +132,23 @@
"time": "Time",
"prev": "Previous",
"next": "Next",
"graph": {
"open": "Show graph",
"title": "Money over time",
"close": "Close",
"loading": "Loading history...",
"noData": "No entries for the selected period.",
"yesterday": "Yesterday",
"range": {
"label": "Range",
"today": "Today",
"24h": "Last 24 hours",
"week": "Last week",
"month": "Last month",
"year": "Last year",
"all": "All history"
}
},
"activities": {
"Product sale": "Product sale",
"Production cost": "Production cost",
@@ -191,6 +203,29 @@
"income": "Income",
"incomeUpdated": "Salary has been successfully updated."
},
"production": {
"title": "Production",
"info": "Details about production in the branch.",
"selectProduct": "Select product",
"quantity": "Quantity",
"storageAvailable": "Free storage",
"cost": "Cost",
"duration": "Duration",
"revenue": "Revenue",
"start": "Start production",
"success": "Production started successfully!",
"error": "Error starting production.",
"minutes": "Minutes",
"ending": "Ending:",
"time": "Time",
"current": "Running productions",
"product": "Product",
"remainingTime": "Remaining time (seconds)",
"status": "Status",
"sleep": "Paused",
"active": "Active",
"noProductions": "No running productions."
},
"vehicles": {
"cargo_cart": "Cargo cart",
"ox_cart": "Ox cart",
@@ -222,13 +257,87 @@
}
},
"nobility": {
"cooldown": "You can only advance again on {date}.",
"cooldown": "You can only advance again on {date}."
},
"mood": {
"happy": "Happy",
"sad": "Sad",
"angry": "Angry",
"calm": "Calm",
"nervous": "Nervous",
"excited": "Excited",
"bored": "Bored",
"fearful": "Fearful",
"confident": "Confident",
"curious": "Curious",
"hopeful": "Hopeful",
"frustrated": "Frustrated",
"lonely": "Lonely",
"grateful": "Grateful",
"jealous": "Jealous",
"guilty": "Guilty",
"apathetic": "Apathetic",
"relieved": "Relieved",
"proud": "Proud",
"ashamed": "Ashamed"
},
"character": {
"brave": "Brave",
"kind": "Kind",
"greedy": "Greedy",
"wise": "Wise",
"loyal": "Loyal",
"cunning": "Cunning",
"generous": "Generous",
"arrogant": "Arrogant",
"honest": "Honest",
"ambitious": "Ambitious",
"patient": "Patient",
"impatient": "Impatient",
"selfish": "Selfish",
"charismatic": "Charismatic",
"empathetic": "Empathetic",
"timid": "Timid",
"stubborn": "Stubborn",
"resourceful": "Resourceful",
"reckless": "Reckless",
"disciplined": "Disciplined",
"optimistic": "Optimistic",
"pessimistic": "Pessimistic",
"manipulative": "Manipulative",
"independent": "Independent",
"dependent": "Dependent",
"adventurous": "Adventurous",
"humble": "Humble",
"vengeful": "Vengeful",
"pragmatic": "Pragmatic",
"idealistic": "Idealistic"
},
"healthview": {
"title": "Health",
"age": "Age",
"status": "Health Status",
"measuresTaken": "Measures Taken",
"measure": "Measure",
"date": "Date",
"cost": "Cost",
"success": "Success",
"selectMeasure": "Select Measure",
"perform": "Perform",
"measures": {
"pill": "Pill",
"doctor": "Doctor Visit",
"witch": "Witch",
"drunkOfLife": "Elixir of Life",
"barber": "Barber"
},
"choose": "Please select",
"errors": {
"tooSoon": "Advancement too soon.",
"unmet": "The following requirements are not met:",
"generic": "Advancement failed."
}
},
"tooClose": "You cannot perform measures so often.",
"generic": "An error occurred."
},
"nextMeasureAt": "Next measure from"
},
"branchProduction": {
"storageAvailable": "Free storage"
},
@@ -254,9 +363,13 @@
"region": "Region",
"date": "Date",
"candidacy": "Candidacy",
"candidacyWithAge": "Candidacy (from age 16)",
"none": "No open positions.",
"apply": "Apply for selected positions"
"apply": "Apply for selected positions",
"minAgeHint": "Candidacy is only possible from age 16.",
"ageRequirement": "All political offices require candidates to be at least 16 years old."
},
"too_young": "Your character is too young. Applications are only possible from age 16.",
"upcoming": {
"office": "Office",
"region": "Region",
@@ -338,6 +451,7 @@
"spouse": {
"wooing": {
"cancel": "Cancel wooing",
"cancelConfirm": "Do you really want to cancel wooing? Progress will be lost.",
"cancelSuccess": "Wooing has been cancelled.",
"cancelError": "Wooing could not be cancelled.",
"cancelTooSoon": "You can only cancel wooing after 24 hours."
@@ -353,6 +467,143 @@
"success": "The gift has been given.",
"nextGiftAt": "Next gift from"
}
},
"church": {
"title": "Church",
"tabs": {
"current": "Current Positions",
"available": "Available Positions",
"applications": "Applications"
},
"current": {
"office": "Office",
"region": "Region",
"holder": "Holder",
"supervisor": "Supervisor",
"none": "No current positions available."
},
"available": {
"office": "Office",
"region": "Region",
"supervisor": "Supervisor",
"seats": "Available Seats",
"action": "Action",
"apply": "Apply",
"applySuccess": "Application submitted successfully.",
"applyError": "Error submitting application.",
"none": "No available positions."
},
"applications": {
"office": "Office",
"region": "Region",
"applicant": "Applicant",
"date": "Date",
"action": "Action",
"approve": "Approve",
"reject": "Reject",
"approveSuccess": "Application approved.",
"rejectSuccess": "Application rejected.",
"decideError": "Error making decision.",
"none": "No applications available."
},
"offices": {
"lay-preacher": "Lay Preacher",
"village-priest": "Village Priest",
"parish-priest": "Parish Priest",
"dean": "Dean",
"archdeacon": "Archdeacon",
"bishop": "Bishop",
"archbishop": "Archbishop",
"cardinal": "Cardinal",
"pope": "Pope"
},
"baptism": {
"title": "Baptism",
"table": {
"name": "First Name",
"gender": "Gender",
"age": "Age",
"baptise": "Baptize (50)",
"newName": "Suggest Name"
},
"gender": {
"male": "Boy",
"female": "Girl"
},
"success": "The child has been baptized.",
"error": "The child could not be baptized."
}
},
"reputation": {
"title": "Reputation",
"overview": {
"title": "Overview",
"current": "Current Reputation"
},
"actions": {
"title": "Reputation Actions",
"description": "You can perform various actions to improve your reputation.",
"none": "No reputation actions available.",
"action": "Action",
"cost": "Cost",
"gain": "Gain",
"timesUsed": "Used",
"execute": "Execute",
"running": "Running...",
"dailyLimit": "Daily limit: {remaining} of {cap} actions remaining",
"cooldown": "Cooldown: {minutes} minutes remaining",
"type": {
"soup_kitchen": "Soup Kitchen",
"library_donation": "Library Donation",
"scholarships": "Scholarships",
"church_hospice": "Church Hospice",
"school_funding": "School Funding",
"orphanage_build": "Build Orphanage",
"bridge_build": "Build Bridge",
"hospital_donation": "Hospital Donation",
"patronage": "Patronage",
"statue_build": "Build Statue",
"well_build": "Build Well"
}
},
"party": {
"title": "Parties",
"totalCost": "Total Cost",
"order": "Order Party",
"inProgress": "Parties in Preparation",
"completed": "Completed Parties",
"newpartyview": {
"open": "Create New Party",
"close": "Hide New Party",
"type": "Party Type"
},
"music": {
"label": "Music",
"none": "No Music",
"bard": "A Bard",
"villageBand": "A Village Band",
"chamberOrchestra": "A Chamber Orchestra",
"symphonyOrchestra": "A Symphony Orchestra",
"symphonyOrchestraWithChorusAndSolists": "A Symphony Orchestra with Chorus and Soloists"
},
"banquette": {
"label": "Food",
"bread": "Bread",
"roastWithBeer": "Roast with Beer",
"poultryWithVegetablesAndWine": "Poultry with Vegetables and Wine",
"extensiveBuffet": "Festive Meal"
},
"servants": {
"label": "One servant per ",
"perPersons": " persons"
},
"esteemedInvites": {
"label": "Invited Estates"
},
"type": "Party Type",
"cost": "Cost",
"date": "Date"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"activate": {
"title": "Activar",
"message": "Hola {username}. Introduce aquí el código que te hemos enviado por correo electrónico.",
"token": "Token:",
"submit": "Enviar",
"failure": "La activación no se ha realizado correctamente."
}
}

View File

@@ -0,0 +1,349 @@
{
"admin": {
"interests": {
"title": "[Admin] - Administrar intereses",
"newinterests": {
"name": "Nombre del interés",
"status": "Aprobado",
"adultonly": "Solo para adultos",
"translations": "Traducciones",
"isactive": "Activado",
"isadult": "Solo para adultos",
"delete": "Eliminar"
}
},
"contacts": {
"title": "[Admin] - Solicitudes de contacto",
"date": "Fecha",
"from": "Remitente",
"actions": "Acciones",
"open": "Editar",
"finished": "Finalizar"
},
"editcontactrequest": {
"title": "[Admin] - Editar solicitud de contacto"
},
"user": {
"name": "Nombre de usuario",
"active": "Activo",
"blocked": "Bloqueado",
"actions": "Acciones",
"search": "Buscar"
},
"rights": {
"add": "Añadir permiso",
"select": "Por favor, selecciona",
"current": "Permisos actuales"
},
"forum": {
"title": "[Admin] - Forum",
"currentForums": "Foros existentes",
"edit": "Editar",
"delete": "Eliminar",
"createForum": "Crear",
"forumName": "Titel",
"create": "Crear",
"permissions": {
"label": "Berechtigungen",
"all": "Jeder",
"admin": "Nur Admins",
"teammember": "Nur Teammitglieder",
"user": "Nur bestimmte Benutzer",
"age": "Nur ab Alter 14"
},
"selectPermissions": "Por favor, selecciona",
"confirmDeleteMessage": "¿De verdad quieres eliminar el foro?",
"confirmDeleteTitle": "Eliminar foro"
},
"falukant": {
"edituser": {
"title": "Editar usuario de Falukant",
"username": "Nombre de usuario",
"characterName": "Nombre del personaje",
"user": "Usuario",
"success": "Los cambios se han guardado.",
"error": "No se pudieron guardar los cambios.",
"errorLoadingBranches": "Error al cargar las sucursales.",
"errorUpdatingStock": "Error al actualizar el almacén.",
"stockUpdated": "Almacén actualizado correctamente.",
"search": "Buscar",
"tabs": {
"userdata": "Datos del usuario",
"branches": "Sucursales"
},
"branches": {
"title": "Sucursales y almacén",
"noStocks": "No hay almacén",
"noBranches": "No se han encontrado sucursales",
"addStock": "Añadir almacén",
"stockType": "Tipo de almacén",
"selectStockType": "Seleccionar tipo de almacén",
"quantity": "Cantidad",
"allStocksAdded": "Todos los tipos de almacén disponibles ya existen"
},
"errorLoadingStockTypes": "Error al cargar los tipos de almacén.",
"errorAddingStock": "Error al añadir el almacén.",
"stockAdded": "Almacén añadido correctamente.",
"invalidStockData": "Por favor, introduce un tipo de almacén y una cantidad válidos."
},
"map": {
"title": "Editor de mapas de Falukant (regiones)",
"description": "Dibuja rectángulos en el mapa de Falukant y asígnalos a ciudades.",
"tabs": {
"regions": "Posiciones",
"distances": "Distancias"
},
"regionList": "Ciudades",
"noCoords": "No se han establecido coordenadas",
"currentRect": "Rectángulo actual",
"hintDraw": "Elige una ciudad y dibuja un rectángulo con el ratón sobre el mapa para definir la posición.",
"saveAll": "Guardar todas las ciudades modificadas",
"connectionsTitle": "Conexiones (region_distance)",
"source": "Desde",
"target": "Hacia",
"selectSource": "Elegir ciudad origen",
"selectTarget": "Elegir ciudad destino",
"mode": "Transportart",
"modeLand": "Land",
"modeWater": "Wasser",
"modeAir": "Luft",
"distance": "Distancia",
"saveConnection": "Guardar conexión",
"pickOnMap": "Seleccionar en el mapa",
"errorSaveConnection": "No se pudo guardar la conexión.",
"errorDeleteConnection": "No se pudo eliminar la conexión.",
"confirmDeleteConnection": "¿Eliminar la conexión?"
},
"createNPC": {
"title": "Crear NPC",
"region": "Ciudad",
"allRegions": "Todas las ciudades",
"ageRange": "Rango de edad",
"to": "hasta",
"years": "años",
"titleRange": "Rango de títulos",
"count": "Cantidad por combinación ciudad-título",
"countHelp": "Esta cantidad se crea para cada combinación de ciudad y título seleccionados.",
"create": "Crear NPC",
"creating": "Creando...",
"result": "Resultado",
"createdCount": "Se han creado {count} NPC.",
"combinationInfo": "{perCombination} NPC por combinación × {combinations} combinaciones = {count} NPC en total",
"age": "Edad",
"errorLoadingRegions": "Error al cargar las ciudades.",
"errorLoadingTitles": "Error al cargar los títulos.",
"errorCreating": "Error al crear los NPC.",
"invalidAgeRange": "Rango de edad no válido.",
"invalidTitleRange": "Rango de títulos no válido.",
"invalidCount": "Cantidad no válida (1-500).",
"progress": "Progreso",
"progressDetails": "{current} de {total} NPC creados",
"timeRemainingSeconds": "Tiempo restante: {seconds} segundos",
"timeRemainingMinutes": "Tiempo restante: {minutes} minutos {seconds} segundos",
"almostDone": "Casi listo...",
"jobNotFound": "Trabajo no encontrado o caducado."
}
},
"chatrooms": {
"title": "[Admin] - Administrar salas de chat",
"roomName": "Nombre de la sala",
"create": "Crear sala de chat",
"edit": "Editar sala de chat",
"type": "Typ",
"isPublic": "Visible públicamente",
"actions": "Acciones",
"genderRestriction": {
"show": "Activar restricción de género",
"label": "Restricción de género"
},
"minAge": {
"show": "Indicar edad mínima",
"label": "Edad mínima"
},
"maxAge": {
"show": "Indicar edad máxima",
"label": "Edad máxima"
},
"password": {
"show": "Activar protección con contraseña",
"label": "Contraseña"
},
"friendsOfOwnerOnly": "Nur Freunde des Besitzers",
"requiredUserRight": {
"show": "Indicar permiso de usuario requerido",
"label": "Permiso de usuario requerido"
},
"roomtype": {
"chat": "Chat",
"dice": "Dados",
"poker": "Poker",
"hangman": "Hangman"
},
"rights": {
"talk": "Hablar",
"scream": "Gritar",
"whisper": "Susurrar",
"start game": "Iniciar juego",
"open room": "Abrir sala",
"systemmessage": "Mensaje del sistema"
},
"confirmDelete": "¿De verdad quieres eliminar esta sala de chat?"
},
"match3": {
"title": "Administrar niveles de Match3",
"newLevel": "Crear nuevo nivel",
"editLevel": "Editar nivel",
"deleteLevel": "Eliminar nivel",
"confirmDelete": "¿De verdad quieres eliminar este nivel?",
"levelName": "Name",
"levelDescription": "Descripción",
"boardWidth": "Breite",
"boardHeight": "Altura",
"moveLimit": "Zug-Limit",
"levelOrder": "Reihenfolge",
"boardLayout": "Board-Layout",
"tileTypes": "Tipos de fichas disponibles",
"actions": "Acciones",
"edit": "Editar",
"delete": "Eliminar",
"save": "Guardar",
"cancel": "Cancelar",
"update": "Actualizar",
"create": "Crear",
"boardControls": {
"fillAll": "Activar todo",
"clearAll": "Desactivar todo",
"invert": "Invertir"
},
"loading": "Cargando niveles...",
"retry": "Reintentar",
"availableLevels": "Niveles disponibles: {count}",
"levelFormat": "Level {number}: {name}",
"levelObjectives": "Level-Objekte",
"objectivesTitle": "Siegvoraussetzungen",
"addObjective": "Añadir objetivo",
"removeObjective": "Eliminar",
"objectiveType": "Typ",
"objectiveTypeScore": "Punkte sammeln",
"objectiveTypeMatches": "Matches machen",
"objectiveTypeMoves": "Usar movimientos",
"objectiveTypeTime": "Zeit einhalten",
"objectiveTypeSpecial": "Spezialziel",
"objectiveOperator": "Operator",
"operatorGreaterEqual": "Mayor o igual (≥)",
"operatorLessEqual": "Menor o igual (≤)",
"operatorEqual": "Gleich (=)",
"operatorGreater": "Mayor que (>)",
"operatorLess": "Menor que (<)",
"objectiveTarget": "Zielwert",
"objectiveTargetPlaceholder": "z.B. 100",
"objectiveOrder": "Reihenfolge",
"objectiveOrderPlaceholder": "1, 2, 3...",
"objectiveDescription": "Descripción",
"objectiveDescriptionPlaceholder": "z.B. Sammle 100 Punkte",
"objectiveRequired": "Requerido para completar el nivel",
"noObjectives": "No hay condiciones de victoria definidas. Haz clic en 'Añadir objetivo' para crear una."
},
"userStatistics": {
"title": "[Admin] - Estadísticas de usuarios",
"totalUsers": "Total de usuarios",
"genderDistribution": "Distribución por género",
"ageDistribution": "Distribución por edad"
},
"taxiTools": {
"title": "Taxi-Tools",
"description": "Administra mapas, niveles y configuraciones de Taxi",
"mapEditor": {
"title": "Editar mapa",
"availableMaps": "Mapas disponibles: {count}",
"newMap": "Crear nuevo mapa",
"mapFormat": "{name} (Position: {x},{y})",
"mapName": "Map-Name",
"mapDescription": "Descripción",
"mapWidth": "Breite",
"mapHeight": "Altura",
"tileSize": "Tamaño de las fichas",
"positionX": "X-Position",
"positionY": "Y-Position",
"mapType": "Map-Typ",
"mapLayout": "Map-Layout",
"tilePalette": "Tile-Palette",
"streetNames": "Nombres de calles",
"extraElements": "Elementos adicionales",
"streetNameHorizontal": "Nombre de calle (horizontal)",
"streetNameVertical": "Nombre de calle (vertical)",
"continueHorizontal": "Continuar en otra dirección (→)",
"continueVertical": "Continuar en otra dirección (↓)",
"continueOther": "Continuar en otra dirección",
"position": "Position",
"fillAllRoads": "Todas las calles",
"clearAll": "Borrar todo",
"generateRandom": "Generar aleatoriamente",
"delete": "Eliminar",
"update": "Actualizar",
"cancel": "Cancelar",
"create": "Crear",
"createSuccess": "¡El mapa se ha creado correctamente!",
"updateSuccess": "¡El mapa se ha actualizado correctamente!",
"deleteSuccess": "¡El mapa se ha eliminado correctamente!"
}
},
"servicesStatus": {
"title": "Service-Status",
"description": "Supervisa el estado del backend, el chat y el daemon",
"status": {
"connected": "Conectado",
"connecting": "Conectando...",
"disconnected": "Desconectado",
"error": "Error",
"unknown": "Desconocido"
},
"backend": {
"title": "Backend",
"connected": "El servicio de backend está accesible y conectado"
},
"chat": {
"title": "Chat",
"connected": "El servicio de chat está accesible y conectado"
},
"daemon": {
"title": "Daemon",
"connected": "El servicio daemon está accesible y conectado",
"connections": {
"title": "Aktive Verbindungen",
"none": "No hay conexiones activas",
"userId": "Benutzer-ID",
"username": "Benutzername",
"connections": "Verbindungen",
"duration": "Verbindungsdauer",
"lastPong": "Zeit seit letztem Pong",
"pingTimeouts": "Ping-Timeouts",
"pongReceived": "Pong empfangen",
"yes": "Ja",
"no": "Nein",
"notConnected": "Daemon no conectado",
"sendError": "Error al enviar la solicitud",
"error": "Error al obtener las conexiones"
},
"websocketLog": {
"title": "WebSocket-Log",
"showLog": "WebSocket-Log anzeigen",
"refresh": "Aktualisieren",
"loading": "Cargando...",
"close": "Cerrar",
"entryCount": "{count} entradas",
"noEntries": "No hay entradas de registro",
"notConnected": "Daemon no conectado",
"sendError": "Error al enviar la solicitud",
"parseError": "Error al procesar la respuesta",
"timestamp": "Zeitstempel",
"direction": "Richtung",
"peer": "Peer",
"connUser": "Verbindungs-User",
"targetUser": "Ziel-User",
"event": "Event"
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"blog": {
"posts": "Publicaciones",
"noPosts": "No hay publicaciones.",
"newPost": "Escribir nueva publicación",
"title": "Blog",
"publish": "Publicar",
"pickImage": "Seleccionar imagen",
"uploadImage": "Subir imagen"
}
}

View File

@@ -0,0 +1,157 @@
{
"chat": {
"multichat": {
"title": "Multi-Chat",
"autoscroll": "Desplazamiento automático",
"options": "Opciones",
"send": "Enviar",
"shout": "Gritar",
"action": "Acción",
"roll": "Tirar dados",
"colorpicker": "Elegir color",
"colorpicker_preview": "Vista previa: Este mensaje usa el color elegido.",
"hex": "HEX",
"invalid_hex": "Valor HEX no válido",
"hue": "Tono",
"saturation": "Saturación",
"lightness": "Luminosidad",
"ok": "Ok",
"cancel": "Cancelar",
"placeholder": "Escribe un mensaje...",
"action_select_user": "Selecciona un usuario",
"action_to": "Acción a {to}",
"action_phrases": {
"left_room": "cambia a la sala",
"leaves_room": "sale de la sala",
"left_chat": "ha salido del chat."
},
"system": {
"room_entered": "Has entrado en la sala \"{room}\".",
"user_entered_room": "{user} ha entrado en la sala.",
"user_left_room": "{user} ha salido de la sala.",
"color_changed_self": "Has cambiado tu color a {color}.",
"color_changed_user": "{user} ha cambiado su color a {color}."
},
"status": {
"connecting": "Conectando…",
"connected": "Conectado",
"disconnected": "Desconectado",
"error": "Error de conexión"
},
"reloadRooms": "Recargar salas",
"createRoom": {
"toggleShowChat": "Mostrar chat",
"toggleCreateRoom": "Crear sala",
"title": "Crear nueva sala",
"commandPrefix": "Comando",
"labels": {
"roomName": "Nombre de la sala",
"visibility": "Visibilidad",
"gender": "Género",
"minAge": "Edad mínima",
"maxAge": "Edad máxima",
"password": "Contraseña",
"rightId": "Permiso requerido",
"typeId": "Tipo de sala",
"friendsOnly": "friends_only=true"
},
"placeholders": {
"roomName": "p. ej. Lounge",
"password": "sin espacios"
},
"options": {
"none": "(ninguno)",
"visibilityPublic": "Pública",
"visibilityPrivate": "Privada",
"genderMale": "Masculino",
"genderFemale": "Femenino",
"genderAny": "Cualquiera / Sin restricción"
},
"actions": {
"create": "Crear sala",
"reset": "Restablecer"
},
"validation": {
"roomNameRequired": "El nombre de la sala es obligatorio.",
"minAgeInvalid": "min_age debe ser >= 0.",
"maxAgeInvalid": "max_age debe ser >= 0.",
"ageRangeInvalid": "min_age no puede ser mayor que max_age.",
"passwordSpaces": "La contraseña no debe contener espacios.",
"rightIdInvalid": "right_id debe ser > 0.",
"typeIdInvalid": "type_id debe ser > 0."
},
"messages": {
"noConnection": "Sin conexión con el servidor de chat.",
"invalidForm": "Corrige los datos del formulario de sala.",
"roomNameMissing": "Introduce un nombre de sala.",
"sent": "Creación de sala enviada: {command}",
"created": "La sala \"{room}\" se ha creado correctamente.",
"createNotConfirmed": "La sala \"{room}\" aún no está confirmada. Revisa la lista de salas."
},
"ownedRooms": {
"title": "Mis salas creadas",
"hint": "Eliminar con comando del daemon: /dr <sala> (alias: /delete_room <sala>)",
"empty": "Aún no tienes salas propias.",
"public": "public",
"private": "private",
"confirmDelete": "¿Seguro que quieres eliminar la sala \"{room}\"?",
"deleteSent": "Comando de borrado enviado: /dr {room}",
"deleteError": "No se pudo eliminar la sala."
},
"rights": {
"mainadmin": "Administrador principal",
"contactrequests": "Solicitudes de contacto",
"users": "Usuarios",
"userrights": "Permisos de usuario",
"forum": "Foro",
"interests": "Intereses",
"falukant": "Falukant",
"minigames": "Minijuegos",
"match3": "Match3",
"taxiTools": "Herramientas de Taxi",
"chatrooms": "Salas de chat",
"servicesStatus": "Estado de servicios"
},
"types": {}
},
"password": {
"title": "Contraseña requerida",
"inputLabel": "Introduce la contraseña",
"submit": "Entrar en sala",
"cancel": "Cancelar",
"requiredPrompt": "La sala \"{room}\" está protegida por contraseña. Introduce la contraseña:",
"invalidPrompt": "Contraseña incorrecta para \"{room}\". Inténtalo de nuevo:",
"cancelled": "Se canceló el acceso a \"{room}\".",
"empty": "La contraseña no puede estar vacía."
}
},
"randomchat": {
"title": "Chat aleatorio",
"age": "Edad",
"gender": {
"title": "Tu género",
"male": "Masculino",
"female": "Femenino"
},
"start": "Empezar",
"agerange": "Edad",
"gendersearch": "Géneros",
"camonly": "Solo con cámara",
"showcam": "Mostrar mi cámara",
"addfriend": "Añadir a amigos",
"close": "Terminar chat",
"autosearch": "Buscar automáticamente",
"input": "Tu texto",
"waitingForMatch": "Esperando a un participante...",
"chatpartner": "Ahora estás chateando con una persona <gender> de <age> años.",
"partnergenderm": "masculina",
"partnergenderf": "femenina",
"self": "Tú",
"partner": "Partner",
"jumptonext": "Finalizar este chat",
"userleftchat": "La otra persona ha salido del chat.",
"startsearch": "Buscar la siguiente charla",
"selfstopped": "Has salido de la conversación."
}
}
}

View File

@@ -0,0 +1,7 @@
{
"error": {
"title": "Error",
"close": "Cerrar",
"credentialsinvalid": "Las credenciales no son correctas."
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"friends": {
"title": "Amigos",
"tabs": {
"existing": "Actuales",
"rejected": "Rechazadas",
"pending": "Pendientes",
"requested": "Solicitadas"
},
"actions": {
"end": "Finalizar",
"accept": "Aceptar",
"reject": "Rechazar",
"withdraw": "Retirar"
},
"headers": {
"name": "Nombre",
"age": "Edad",
"gender": "Género",
"actions": "Acciones"
}
}
}

View File

@@ -0,0 +1,61 @@
{
"welcome": "Bienvenido a YourPart",
"imprint": {
"title": "Aviso legal",
"button": "Aviso legal"
},
"dataPrivacy": {
"title": "Política de privacidad",
"button": "Política de privacidad"
},
"contact": {
"title": "Contacto",
"button": "Contacto"
},
"error-title": "Error",
"warning-title": "Advertencia",
"info-title": "Información",
"dialog": {
"contact": {
"email": "Dirección de correo electrónico",
"name": "Nombre",
"message": "Tu mensaje",
"accept": "Tu dirección de correo electrónico se guardará temporalmente en nuestro sistema. Una vez gestionada tu solicitud, se eliminará de nuevo.",
"acceptdatasave": "Acepto el almacenamiento temporal de mi dirección de correo electrónico.",
"accept2": "Sin este consentimiento no podemos responderte."
}
},
"general": {
"datetimelong": "dd.MM.yyyy HH:mm:ss",
"loading": "Cargando...",
"back": "Atrás",
"cancel": "Cancelar",
"yes": "Sí",
"no": "No"
},
"OK": "Ok",
"Cancel": "Cancelar",
"yes": "Sí",
"no": "No",
"message": {
"close": "Cerrar"
},
"gender": {
"male": "Masculino",
"female": "Femenino",
"transmale": "Hombre trans",
"transfemale": "Mujer trans",
"nonbinary": "No binario"
},
"common": {
"edit": "Editar",
"delete": "Eliminar",
"create": "Crear",
"update": "Actualizar",
"save": "Guardar",
"add": "Añadir",
"cancel": "Cancelar",
"yes": "Sí",
"no": "No"
}
}

View File

@@ -0,0 +1,5 @@
{
"logo": "Logo",
"title": "YourPart",
"advertisement": "Publicidad"
}

View File

@@ -0,0 +1,46 @@
{
"home": {
"betaNoticeLabel": "Aviso beta:",
"betaNoticeText": "YourPart está en desarrollo activo. Algunas funciones pueden estar incompletas, pueden faltar contenidos y puede haber cambios.",
"nologin": {
"welcome": "Bienvenido a yourPart",
"description": "yourPart es una red social donde puedes hacer amistades y conocer gente nueva. Aquí puedes mostrar tus imágenes y decidir quién puede verlas. Por supuesto, también puedes intercambiar mensajes y chatear: en grande, con muchos a la vez, o en un chat aleatorio 1 a 1. Y no lo olvides: también puedes usar la cámara.",
"introHtml": "YourPart es una plataforma en línea en crecimiento que combina funciones de comunidad, chat en tiempo real, foros, una red social con galería de imágenes y el juego de estrategia <em>Falukant</em>. Actualmente el sitio está en fase beta: ampliamos continuamente funciones, contenidos y estabilidad.",
"expected": {
"title": "Qué te espera",
"items": {
"chat": "<strong>Chat</strong>: Salas públicas, encuentros aleatorios (chat aleatorio) y personalización de colores.",
"social": "<strong>Red social</strong>: Perfil, amistades, galería de imágenes con configuraciones de visibilidad.",
"forum": "<strong>Foro</strong>: Crear temas, escribir mensajes, permisos de moderación (por roles).",
"falukant": "<strong>Falukant</strong>: Economía y vida cotidiana: gestionar sucursales, producir, almacenar y vender.",
"minigames": "<strong>Minijuegos</strong>: por ejemplo, niveles de Match-3 para entretenimiento rápido.",
"multilingual": "<strong>Multilingüe</strong>: Alemán/inglés; el contenido se amplía continuamente."
}
},
"falukantShort": {
"title": "Falukant: en breve",
"text": "En Falukant diriges negocios, desarrollas conocimiento, optimizas producción y ventas, vigilas precios y reaccionas a eventos. Las notificaciones te informan en tiempo real sobre cambios de estado."
},
"privacyBeta": {
"title": "Privacidad y estado beta",
"text": "YourPart está en beta. Puede haber cambios, interrupciones y traducciones incompletas. Valoramos la privacidad y la transparencia; habrá más información a lo largo de la beta."
},
"getStarted": {
"title": "Participa",
"text": "Ya puedes usar la plataforma, probarla y darnos tu opinión. Regístrate mediante “{register}” o inicia el chat aleatorio sin compromiso."
},
"randomchat": "Chat aleatorio",
"startrandomchat": "Iniciar chat aleatorio",
"login": {
"name": "Nombre de usuario",
"namedescription": "Introduce tu nombre de usuario",
"password": "Contraseña",
"passworddescription": "Introduce tu contraseña",
"lostpassword": "He olvidado la contraseña",
"register": "Registrarse en yourPart",
"stayLoggedIn": "Mantener la sesión iniciada",
"submit": "Iniciar sesión"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"message": {
"title": "Mensaje",
"close": "Cerrar",
"test": "La prueba funciona",
"success": "La acción se ha realizado correctamente."
}
}

View File

@@ -0,0 +1,73 @@
{
"minigames": {
"title": "Minijuegos",
"description": "¡Descubre una colección de divertidos minijuegos!",
"play": "Jugar",
"backToGames": "Volver a los juegos",
"comingSoon": {
"title": "Próximamente",
"description": "¡Más juegos emocionantes están en desarrollo!"
},
"match3": {
"title": "Match 3 - Juwelen Kampagne",
"description": "¡Combina tres o más gemas iguales para sumar puntos!",
"campaignDescription": "¡Juega todos los niveles y consigue estrellas!",
"gameStats": "Estadísticas del juego",
"score": "Puntos",
"moves": "Movimientos",
"currentLevel": "Nivel actual",
"level": "Level",
"stars": "Estrellas",
"movesLeft": "Movimientos restantes",
"restartLevel": "Reiniciar nivel",
"pause": "Pause",
"resume": "Reanudar",
"paused": "Juego en pausa",
"levelComplete": "¡Nivel completado!",
"levelScore": "Puntuación del nivel",
"movesUsed": "Movimientos usados",
"starsEarned": "Estrellas conseguidas",
"nextLevel": "Siguiente nivel",
"campaignComplete": "¡Campaña completada!",
"totalScore": "Puntuación total",
"totalStars": "Estrellas totales",
"levelsCompleted": "Niveles completados",
"restartCampaign": "Reiniciar campaña"
},
"taxi": {
"title": "Taxi Simulator",
"description": "¡Lleva pasajeros por la ciudad y gana dinero!",
"gameStats": "Estadísticas del juego",
"score": "Puntos",
"money": "Dinero",
"passengers": "Pasajeros",
"currentLevel": "Nivel actual",
"level": "Level",
"fuel": "Combustible",
"fuelLeft": "Combustible restante",
"restartLevel": "Reiniciar nivel",
"pause": "Pause",
"resume": "Reanudar",
"paused": "Juego en pausa",
"levelComplete": "¡Nivel completado!",
"levelScore": "Puntuación del nivel",
"moneyEarned": "Dinero ganado",
"passengersDelivered": "Pasajeros entregados",
"nextLevel": "Siguiente nivel",
"campaignComplete": "¡Campaña completada!",
"totalScore": "Puntuación total",
"totalMoney": "Dinero total",
"levelsCompleted": "Niveles completados",
"restartCampaign": "Reiniciar campaña",
"pickupPassenger": "Recoger pasajero",
"deliverPassenger": "Dejar pasajero",
"refuel": "Repostar",
"startEngine": "Arrancar motor",
"stopEngine": "Parar motor",
"crash": {
"title": "¡Accidente!",
"message": "¡Has tenido un accidente! Choques: {crashes}"
}
}
}
}

View File

@@ -0,0 +1,116 @@
{
"navigation": {
"home": "Inicio",
"logout": "Cerrar sesión",
"friends": "Amigos",
"socialnetwork": "Punto de encuentro",
"chats": "Chats",
"falukant": "Falukant",
"minigames": "Minijuegos",
"personal": "Personal",
"settings": "Ajustes",
"administration": "Administración",
"m-chats": {
"multiChat": "Chat multiusuario",
"randomChat": "Chat aleatorio (1 a 1)",
"eroticChat": "Chat erótico"
},
"m-socialnetwork": {
"guestbook": "Libro de visitas",
"blog": "Blog",
"usersearch": "Búsqueda de usuarios",
"forum": "Forum",
"gallery": "Galería",
"sprachenlernen": "Aprender idiomas",
"blockedUsers": "Usuarios bloqueados",
"oneTimeInvitation": "Invitaciones de un solo uso",
"diary": "Diario",
"erotic": "Erotik",
"m-erotic": {
"pictures": "Imágenes",
"videos": "Videos"
},
"m-sprachenlernen": {
"vocabtrainer": "Entrenador de vocabulario",
"sprachkurse": "Cursos de idiomas",
"m-vocabtrainer": {
"newLanguage": "Nuevo idioma"
}
}
},
"m-minigames": {
"match3": "Match 3 - Juwelen",
"taxi": "Taxi Simulator"
},
"m-personal": {
"sprachenlernen": "Aprender idiomas",
"calendar": "Calendario",
"m-sprachenlernen": {
"vocabtrainer": "Entrenador de vocabulario",
"sprachkurse": "Cursos de idiomas",
"m-vocabtrainer": {
"newLanguage": "Nuevo idioma"
}
}
},
"m-settings": {
"homepage": "Página de inicio",
"account": "Account",
"personal": "Personal",
"view": "Apariencia",
"flirt": "Flirt",
"interests": "Interessen",
"notifications": "Notificaciones",
"sexuality": "Sexualidad"
},
"m-administration": {
"contactrequests": "Solicitudes de contacto",
"users": "Usuarios",
"userrights": "Permisos de usuario",
"m-users": {
"userlist": "Lista de usuarios",
"userstatistics": "Estadísticas de usuarios",
"userrights": "Permisos de usuario"
},
"forum": "Forum",
"interests": "Interessen",
"falukant": "Falukant",
"m-falukant": {
"logentries": "Entradas de registro",
"edituser": "Editar usuario",
"database": "Datenbank",
"mapEditor": "Editor de mapas",
"createNPC": "Crear NPCs"
},
"minigames": "Minispiele",
"m-minigames": {
"match3": "Match3 Level",
"taxiTools": "Taxi-Tools"
},
"chatrooms": "Salas de chat",
"servicesStatus": "Service-Status"
},
"m-friends": {
"manageFriends": "Gestionar amigos",
"chat": "Chatear",
"profile": "Profil"
},
"m-falukant": {
"create": "Crear",
"overview": "Resumen",
"towns": "Sucursales",
"factory": "Producción",
"family": "Familia",
"house": "Haus",
"darknet": "Untergrund",
"reputation": "Reputation",
"moneyhistory": "Flujo de dinero",
"nobility": "Estatus social",
"politics": "Politik",
"education": "Bildung",
"health": "Gesundheit",
"bank": "Bank",
"church": "Kirche"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"passwordReset": {
"title": "Restablecer contraseña",
"email": "E-Mail",
"reset": "Restablecer",
"success": "Si el correo electrónico existe, se ha enviado una guía para restablecer la contraseña.",
"failure": "No se pudo restablecer la contraseña. Inténtalo de nuevo más tarde."
}
}

View File

@@ -0,0 +1,79 @@
{
"personal": {
"calendar": {
"title": "Calendario",
"today": "Hoy",
"newEntry": "Nueva entrada",
"editEntry": "Editar entrada",
"selectedDays": "{count} días seleccionados",
"createEventForSelection": "Crear evento",
"clearSelection": "Borrar selección",
"allDay": "Todo el día",
"views": {
"month": "Mes",
"week": "Semana",
"workweek": "Semana laboral",
"day": "Día"
},
"weekdays": {
"mon": "Lu",
"tue": "Ma",
"wed": "Mi",
"thu": "Ju",
"fri": "Vi",
"sat": "Sa",
"sun": "Do"
},
"weekdaysFull": {
"mon": "Lunes",
"tue": "Martes",
"wed": "Miércoles",
"thu": "Jueves",
"fri": "Viernes",
"sat": "Sábado",
"sun": "Domingo"
},
"months": {
"jan": "Enero",
"feb": "Febrero",
"mar": "Marzo",
"apr": "Abril",
"may": "Mayo",
"jun": "Junio",
"jul": "Julio",
"aug": "Agosto",
"sep": "Septiembre",
"oct": "Octubre",
"nov": "Noviembre",
"dec": "Diciembre"
},
"categories": {
"personal": "Personal",
"work": "Trabajo",
"family": "Familia",
"health": "Salud",
"birthday": "Cumpleaños",
"holiday": "Vacaciones",
"reminder": "Recordatorio",
"other": "Otros"
},
"form": {
"title": "Título",
"titlePlaceholder": "Introduce un título...",
"category": "Categoría",
"startDate": "Fecha de inicio",
"startTime": "Hora de inicio",
"endDate": "Fecha de fin",
"endTime": "Hora de fin",
"allDay": "Todo el día",
"description": "Descripción",
"descriptionPlaceholder": "Descripción opcional...",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"saveError": "Error al guardar el evento",
"deleteError": "Error al eliminar el evento"
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"register": {
"title": "Registrarse",
"email": "Dirección de correo electrónico",
"username": "Nombre de usuario",
"password": "Contraseña",
"repeatPassword": "Repetir contraseña",
"language": "Idioma",
"languages": {
"en": "Inglés",
"de": "Alemán"
},
"register": "Registrarse",
"close": "Cerrar",
"failure": "Se ha producido un error.",
"success": "Te has registrado correctamente. Revisa tu correo electrónico para activar tu cuenta.",
"passwordMismatch": "Las contraseñas no coinciden.",
"emailinuse": "La dirección de correo electrónico ya está en uso.",
"usernameinuse": "El nombre de usuario no está disponible."
}
}

View File

@@ -0,0 +1,180 @@
{
"settings": {
"personal": {
"title": "Datos personales",
"label": {
"language": "Idioma",
"birthdate": "Fecha de nacimiento",
"gender": "Género",
"town": "Ciudad",
"zip": "Código postal",
"eyecolor": "Color de ojos",
"haircolor": "Color de pelo",
"hairlength": "Longitud del pelo",
"skincolor": "Color de piel",
"freckles": "Pecas",
"weight": "Peso",
"bodyheight": "Altura",
"piercings": "Piercings",
"tattoos": "Tatuajes",
"sexualpreference": "Orientación",
"pubichair": "Vello púbico",
"penislength": "Longitud del pene",
"brasize": "Talla de sujetador",
"willChildren": "Quiero hijos",
"smokes": "Fuma",
"drinks": "Bebe alcohol",
"hasChildren": "Tengo hijos",
"interestedInGender": "Interesado/a en"
},
"tooltip": {
"language": "Idioma",
"birthdate": "Fecha de nacimiento",
"gender": "Género",
"town": "Ciudad",
"zip": "Código postal",
"eyecolor": "Color de ojos",
"haircolor": "Color de pelo",
"hairlength": "Longitud del pelo",
"skincolor": "Color de piel",
"freckles": "Pecas",
"weight": "Peso",
"bodyheight": "Altura",
"piercings": "Piercings",
"tattoos": "Tatuajes",
"sexualpreference": "Orientación",
"pubichair": "Vello púbico",
"penislength": "Longitud del pene",
"brasize": "Talla de sujetador"
},
"gender": {
"male": "Masculino",
"female": "Femenino",
"transmale": "Hombre trans",
"transfemale": "Mujer trans",
"nonbinary": "No binario"
},
"language": {
"de": "Alemán",
"en": "Inglés"
},
"eyecolor": {
"blue": "Azul",
"green": "Verde",
"brown": "Marrón",
"black": "Negro",
"grey": "Gris",
"hazel": "Avellana",
"amber": "Ámbar",
"red": "Rojo",
"other": "Otro"
},
"haircolor": {
"black": "Negro",
"brown": "Castaño",
"blonde": "Rubio",
"red": "Rojo",
"grey": "Gris",
"white": "Blanco",
"other": "Otro"
},
"hairlength": {
"short": "Corto",
"medium": "Medio",
"long": "Largo",
"bald": "Calvo",
"other": "Otro"
},
"skincolor": {
"light": "Clara",
"medium": "Media",
"dark": "Oscura",
"other": "Otra"
},
"freckles": {
"much": "Muchas",
"medium": "Medias",
"less": "Pocas",
"none": "Ninguna"
},
"sexualpreference": {
"straight": "Heterosexual",
"gay": "Homosexual",
"bi": "Bisexual",
"asexual": "Asexual",
"pan": "Pansexual"
},
"pubichair": {
"none": "Ninguno",
"short": "Corto",
"medium": "Medio",
"long": "Largo",
"hairy": "Natural",
"waxed": "Depilación con cera",
"landingstrip": "Franja",
"bikinizone": "Solo zona bikini",
"other": "Otro"
},
"interestedInGender": {
"male": "Hombres",
"female": "Mujeres"
},
"smokes": {
"often": "A menudo",
"socially": "Socialmente",
"daily": "A diario",
"never": "Nunca"
},
"drinks": {
"often": "A menudo",
"socially": "Socialmente",
"daily": "A diario",
"never": "Nunca"
}
},
"view": {
"title": "Apariencia"
},
"sexuality": {
"title": "Sexualidad"
},
"account": {
"title": "Account",
"username": "Nombre de usuario",
"email": "Dirección de correo electrónico",
"newpassword": "Contraseña",
"newpasswordretype": "Repetir contraseña",
"deleteAccount": "Eliminar cuenta",
"language": "Idioma",
"showinsearch": "Mostrar en búsquedas de usuarios",
"changeaction": "Actualizar datos de usuario",
"oldpassword": "Contraseña anterior (obligatoria)"
},
"interests": {
"title": "Intereses",
"new": "Nuevo interés",
"add": "Añadir",
"added": "El nuevo interés se ha añadido y está en revisión. Hasta finalizar, no será visible en la lista de intereses.",
"adderror": "Se produjo un error al añadir el interés.",
"errorsetinterest": "No se pudo asignar el interés."
},
"visibility": {
"Invisible": "No mostrar",
"OnlyFriends": "Solo amigos",
"FriendsAndAdults": "Amigos y adultos",
"AdultsOnly": "Solo adultos",
"All": "Mostrar a todos"
},
"flirt": {
"title": "Flirt"
},
"immutable": {
"tooltip": "Este campo no se puede modificar. Para cambios, contacta con soporte.",
"supportContact": "Contactar con soporte",
"supportMessage": {
"general": "Hola,\n\nquiero solicitar un cambio en mis datos de perfil que no se pueden modificar.\n\nPor favor, contactad conmigo para más detalles.\n\nUn saludo",
"specific": "Hola,\n\nquiero solicitar un cambio en los siguientes datos de perfil que no se pueden modificar: {fields}\n\nPor favor, contactad conmigo para más detalles.\n\nUn saludo"
}
}
}
}

View File

@@ -0,0 +1,415 @@
{
"socialnetwork": {
"usersearch": {
"title": "Búsqueda de usuarios",
"username": "Nombre de usuario",
"age_from": "Edad desde",
"age_to": "bis",
"gender": "Género",
"search_button": "Buscar",
"no_results": "No se han encontrado resultados",
"results_title": "Resultados de la búsqueda:",
"result": {
"nick": "Apodo",
"gender": "Género",
"age": "Edad"
}
},
"profile": {
"pretitle": "Cargando datos. Por favor espera...",
"error_title": "Usuario no encontrado",
"title": "Profil von <username>",
"tab": {
"general": "General",
"sexuality": "Sexualidad",
"images": "Galería",
"guestbook": "Libro de visitas"
},
"values": {
"bool": {
"true": "Sí",
"false": "No"
},
"smokes": {
"never": "Nunca",
"socially": "Socialmente",
"often": "A menudo",
"daily": "A diario"
},
"drinks": {
"never": "Nunca",
"socially": "Socialmente",
"often": "A menudo",
"daily": "A diario"
},
"interestedInGender": {
"male": "hombres",
"female": "mujeres"
},
"sexualpreference": {
"straight": "Heterosexual",
"gay": "Homosexual",
"bi": "Bisexual",
"pan": "Pansexual",
"asexual": "Asexual"
},
"pubichair": {
"none": "Ninguno",
"short": "Corto",
"medium": "Medio",
"long": "Largo",
"hairy": "Natural",
"waxed": "Depilado",
"landingstrip": "Franja",
"other": "Otro",
"bikinizone": "Zona bikini"
},
"gender": {
"male": "Masculino",
"female": "Femenino",
"transmale": "Hombre trans",
"transfemale": "Mujer trans",
"nonbinary": "No binario"
},
"language": {
"de": "Alemán",
"en": "Inglés"
},
"eyecolor": {
"blue": "Azul",
"green": "Verde",
"brown": "Marrón",
"black": "Negro",
"grey": "Gris",
"hazel": "Avellana",
"amber": "Ámbar",
"red": "Rojo",
"other": "Otro"
},
"haircolor": {
"black": "Negro",
"brown": "Castaño",
"blonde": "Rubio",
"red": "Rojo",
"grey": "Gris",
"white": "Blanco",
"other": "Otro"
},
"hairlength": {
"short": "Corto",
"medium": "Medio",
"long": "Largo",
"bald": "Calvo",
"other": "Otro"
},
"skincolor": {
"light": "Clara",
"medium": "Media",
"dark": "Oscura",
"other": "Otra"
},
"freckles": {
"much": "Muchas",
"medium": "Medias",
"less": "Pocas",
"none": "Ninguna"
}
},
"guestbook": {
"showInput": "Mostrar nueva entrada",
"hideInput": "Ocultar nueva entrada",
"imageUpload": "Imagen",
"submit": "Enviar entrada",
"noEntries": "No se han encontrado entradas"
},
"interestedInGender": "Interesado/a en",
"hasChildren": "Tiene hijos",
"smokes": "Fuma",
"drinks": "Alcohol",
"willChildren": "Quiere hijos",
"sexualpreference": "Orientación sexual",
"pubichair": "Vello púbico",
"penislength": "Longitud del pene",
"brasize": "Talla de sujetador",
"piercings": "Piercings",
"tattoos": "Tatuajes",
"language": "Idioma",
"gender": "Género",
"eyecolor": "Color de ojos",
"haircolor": "Color de pelo",
"hairlength": "Longitud del pelo",
"freckles": "Pecas",
"skincolor": "Color de piel",
"birthdate": "Fecha de nacimiento",
"age": "Edad",
"town": "Ciudad",
"bodyheight": "Altura",
"weight": "Peso"
},
"gallery": {
"title": "Galería",
"folders": "Carpetas",
"create_folder": "Crear carpeta",
"upload": {
"title": "Subir imagen",
"image_title": "Título",
"image_file": "Archivo",
"visibility": "Visible para",
"upload_button": "Subir",
"selectvisibility": "Selecciona"
},
"images": "Imágenes",
"visibility": {
"everyone": "Todos",
"friends": "Amigos",
"adults": "Adultos",
"friends-and-adults": "Amigos y adultos",
"selected-users": "Usuarios seleccionados",
"none": "Nadie"
},
"create_folder_dialog": {
"title": "Crear carpeta",
"parent_folder": "Se crea en",
"folder_title": "Nombre de la carpeta",
"visibility": "Visible para",
"select_visibility": "Selecciona"
},
"noimages": "Actualmente no hay imágenes en esta carpeta",
"imagedialog": {
"image_title": "Título",
"edit_visibility": "Visible para",
"save_changes": "Guardar cambios",
"close": "Cerrar",
"edit_visibility_placeholder": "Selecciona"
},
"delete_folder_confirmation_title": "Eliminar carpeta",
"delete_folder_confirmation_message": "¿De verdad quieres eliminar la carpeta '%%folderName%%'?",
"edit_image_dialog": {
"title": "Editar datos de la imagen"
},
"show_image_dialog": {
"title": "Imagen"
}
},
"guestbook": {
"title": "Libro de visitas",
"prevPage": "Atrás",
"nextPage": "Siguiente",
"page": "Página"
},
"diary": {
"title": "Diario",
"noEntries": "Aún no has escrito ninguna entrada en el diario.",
"newEntry": "Nueva entrada",
"editEntry": "Editar entrada",
"save": "Guardar",
"update": "Actualizar",
"cancel": "Cancelar",
"edit": "Editar",
"delete": "Eliminar",
"confirmDelete": "¿Seguro que quieres eliminar la entrada?",
"prevPage": "Atrás",
"nextPage": "Siguiente",
"page": "Página"
},
"forum": {
"title": "Forum",
"showNewTopic": "Crear nuevo tema",
"hideNewTopic": "Cancelar creación",
"noTitles": "No hay temas",
"topic": "Tema",
"createNewTopic": "Crear tema",
"createdBy": "Creado por",
"createdAt": "Creado el",
"reactions": "Respuestas",
"lastReaction": "Última respuesta de",
"pagination": {
"first": "Primera página",
"previous": "Página anterior",
"next": "Página siguiente",
"last": "Última página",
"page": "Seite <<page>> von <<of>>"
},
"createNewMesssage": "Enviar respuesta"
},
"friendship": {
"error": {
"alreadyexists": "La solicitud de amistad ya existe"
},
"state": {
"none": "No sois amigos",
"waiting": "Solicitud enviada, aún sin respuesta",
"open": "Solicitud recibida",
"denied": "Solicitud rechazada",
"withdrawn": "Solicitud retirada",
"accepted": "Amigos"
},
"added": "Has enviado una solicitud de amistad.",
"withdrawn": "Has retirado tu solicitud de amistad.",
"denied": "Has rechazado la solicitud de amistad.",
"accepted": "Se ha aceptado la amistad."
},
"vocab": {
"title": "Entrenador de vocabulario",
"description": "Crea idiomas (o suscríbete) y compártelos con tus amigos.",
"newLanguage": "Nuevo idioma",
"newLanguageTitle": "Crear nuevo idioma",
"languageName": "Nombre del idioma",
"create": "Crear",
"saving": "Guardando...",
"created": "El idioma se ha creado.",
"createdTitle": "Entrenador de vocabulario",
"createdMessage": "El idioma se ha creado. El menú se actualizará.",
"createError": "No se pudo crear el idioma.",
"openLanguage": "Abrir",
"none": "Aún no has creado ni te has suscrito a ningún idioma.",
"owner": "Propio",
"subscribed": "Suscrito",
"languageTitle": "Entrenador de vocabulario: {name}",
"notFound": "Idioma no encontrado o sin acceso.",
"shareCode": "Código para compartir",
"shareHint": "Puedes compartir este código con tus amigos para que se suscriban al idioma.",
"subscribeByCode": "Suscribirse con código",
"subscribeTitle": "Suscribirse a un idioma",
"subscribeHint": "Introduce el código para compartir que te ha dado un amigo.",
"subscribe": "Suscribirse",
"subscribeSuccess": "Suscripción correcta. El menú se actualizará.",
"subscribeError": "Fallo en la suscripción. Código inválido o sin acceso.",
"trainerPlaceholder": "Las funciones del entrenador (vocabulario/pruebas) serán el siguiente paso.",
"chapters": "Capítulos",
"newChapter": "Nuevo capítulo",
"createChapter": "Crear capítulo",
"createChapterError": "No se pudo crear el capítulo.",
"noChapters": "Aún no hay capítulos.",
"chapterTitle": "Capítulo: {title}",
"addVocab": "Añadir vocabulario",
"learningWord": "Idioma de aprendizaje",
"referenceWord": "Referencia",
"add": "Añadir",
"addVocabError": "No se pudo añadir el vocabulario.",
"noVocabs": "Aún no hay vocabulario en este capítulo.",
"practice": {
"open": "Practicar",
"title": "Practicar vocabulario",
"allVocabs": "Todo el vocabulario",
"simple": "Práctica simple",
"noPool": "No hay vocabulario para practicar.",
"dirLearningToRef": "Aprendizaje → Referencia",
"dirRefToLearning": "Referencia → Aprendizaje",
"check": "Comprobar",
"next": "Siguiente",
"skip": "Saltar",
"correct": "¡Correcto!",
"wrong": "Incorrecto.",
"acceptable": "Traducciones correctas posibles:",
"stats": "Estadísticas",
"success": "Éxito",
"fail": "Fallo"
},
"search": {
"open": "Buscar",
"title": "Buscar vocabulario",
"term": "Término de búsqueda",
"motherTongue": "Lengua materna",
"learningLanguage": "Idioma de aprendizaje",
"lesson": "Lección",
"search": "Buscar",
"noResults": "Sin resultados.",
"error": "La búsqueda ha fallado."
},
"courses": {
"title": "Cursos de idiomas",
"create": "Crear curso",
"myCourses": "Mis cursos",
"allCourses": "Todos los cursos",
"none": "No se han encontrado cursos.",
"owner": "Propietario",
"enrolled": "Inscrito",
"public": "Público",
"difficulty": "Dificultad",
"lessons": "Lecciones",
"enroll": "Inscribirse",
"continue": "Continuar",
"edit": "Editar",
"addLesson": "Añadir lección",
"completed": "Completado",
"score": "Puntuación",
"review": "Repasar",
"start": "Empezar",
"noLessons": "Este curso aún no tiene lecciones.",
"lessonNumber": "Número de lección",
"chapter": "Capítulo",
"selectChapter": "Seleccionar capítulo",
"selectLanguage": "Seleccionar idioma",
"confirmDelete": "¿Eliminar la lección?",
"titleLabel": "Título",
"descriptionLabel": "Descripción",
"languageLabel": "Idioma",
"findByCode": "Buscar curso por código",
"shareCode": "Share-Code",
"searchPlaceholder": "Buscar curso...",
"allLanguages": "Todos los idiomas",
"targetLanguage": "Idioma objetivo",
"nativeLanguage": "Lengua materna",
"allNativeLanguages": "Todas las lenguas maternas",
"myNativeLanguage": "Mi lengua materna",
"forAllLanguages": "Para todos los idiomas",
"optional": "Opcional",
"invalidCode": "Código inválido",
"courseNotFound": "Curso no encontrado",
"grammarExercises": "Prueba de gramática",
"noExercises": "No hay prueba disponible",
"enterAnswer": "Introduce la respuesta",
"checkAnswer": "Comprobar respuesta",
"correct": "¡Correcto!",
"wrong": "Incorrecto",
"explanation": "Explicación",
"learn": "Aprender",
"exercises": "Prueba del capítulo",
"learnVocabulary": "Aprender vocabulario",
"lessonDescription": "Descripción de la lección",
"culturalNotes": "Notas culturales",
"grammarExplanations": "Explicaciones gramaticales",
"importantVocab": "Términos importantes",
"vocabInfoText": "Estos términos se usarán en la prueba. Apréndelos aquí antes de pasar a la prueba del capítulo.",
"noVocabInfo": "Lee la descripción de arriba y las explicaciones de la prueba para aprender los términos más importantes.",
"vocabTrainer": "Entrenador de vocabulario",
"vocabTrainerDescription": "Practica los términos clave de esta lección de forma interactiva.",
"startVocabTrainer": "Iniciar entrenador",
"stopTrainer": "Detener entrenador",
"translateTo": "Traduce al alemán",
"translateFrom": "Traduce desde bisaya",
"next": "Siguiente",
"totalAttempts": "Intentos",
"successRate": "Tasa de acierto",
"modeMultipleChoice": "Multiple Choice",
"modeTyping": "Texteingabe",
"currentLesson": "Lección actual",
"mixedReview": "Repaso",
"lessonCompleted": "¡Lección completada!",
"goToNextLesson": "¿Pasar a la siguiente lección?",
"allLessonsCompleted": "¡Todas las lecciones completadas!",
"startExercises": "Ir a la prueba del capítulo",
"correctAnswer": "Respuesta correcta",
"alternatives": "Respuestas alternativas",
"notStarted": "No empezado",
"continueCurrentLesson": "Continuar lección actual",
"previousLessonRequired": "Primero completa la lección anterior",
"lessonNumberShort": "#",
"readingAloudInstruction": "Lee el texto en voz alta. Haz clic en 'Iniciar grabación' y comienza a hablar.",
"speakingFromMemoryInstruction": "Habla de memoria. Usa las palabras clave mostradas.",
"startRecording": "Iniciar grabación",
"stopRecording": "Detener grabación",
"startSpeaking": "Empezar a hablar",
"recording": "Grabando",
"listening": "Escuchando...",
"recordingStopped": "Grabación finalizada",
"recordingError": "Error de grabación",
"recognizedText": "Texto reconocido",
"speechRecognitionNotSupported": "El reconocimiento de voz no es compatible con este navegador. Usa Chrome o Edge.",
"keywords": "Palabras clave",
"switchBackToMultipleChoice": "Volver a opción múltiple"
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,58 @@
import BlogListView from '@/views/blog/BlogListView.vue';
import BlogView from '@/views/blog/BlogView.vue';
import BlogEditorView from '@/views/blog/BlogEditorView.vue';
const BlogListView = () => import('@/views/blog/BlogListView.vue');
const BlogView = () => import('@/views/blog/BlogView.vue');
const BlogEditorView = () => import('@/views/blog/BlogEditorView.vue');
import { buildAbsoluteUrl } from '@/utils/seo.js';
export default [
{ path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } },
{ path: '/blogs/:id/edit', name: 'BlogEdit', component: BlogEditorView, props: true, meta: { requiresAuth: true } },
// Slug-only route first so it doesn't get captured by the :id route
{ path: '/blogs/:slug', name: 'BlogSlug', component: BlogView, props: route => ({ slug: route.params.slug }) },
{
path: '/blogs/:slug',
name: 'BlogSlug',
component: BlogView,
props: route => ({ slug: route.params.slug }),
meta: {
seo: {
title: 'Blogs auf YourPart',
description: 'Oeffentliche Blogs, Beitraege und Community-Inhalte auf YourPart.',
},
},
},
// Id-constrained route (numeric id only) with optional slug for canonical links
{ path: '/blogs/:id(\\d+)/:slug?', name: 'Blog', component: BlogView, props: true },
{ path: '/blogs', name: 'BlogList', component: BlogListView },
{
path: '/blogs/:id(\\d+)/:slug?',
name: 'Blog',
component: BlogView,
props: true,
meta: {
seo: {
title: 'Blogs auf YourPart',
description: 'Oeffentliche Blogs, Beitraege und Community-Inhalte auf YourPart.',
},
},
},
{
path: '/blogs',
name: 'BlogList',
component: BlogListView,
meta: {
seo: {
title: 'Blogs auf YourPart - Community-Beitraege und Themen',
description: 'Entdecke oeffentliche Blogs auf YourPart mit Community-Beitraegen, Gedanken, Erfahrungen und Themen aus verschiedenen Bereichen.',
keywords: 'Blogs, Community Blog, Artikel, Beitraege, YourPart',
canonicalPath: '/blogs',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Blogs auf YourPart',
url: buildAbsoluteUrl('/blogs'),
description: 'Oeffentliche Blogs und Community-Beitraege auf YourPart.',
inLanguage: 'de',
},
],
},
},
},
];

View File

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

View File

@@ -1,6 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router';
import store from '../store';
import HomeView from '../views/HomeView.vue';
import authRoutes from './authRoutes';
import socialRoutes from './socialRoutes';
import settingsRoutes from './settingsRoutes';
@@ -9,13 +8,41 @@ import falukantRoutes from './falukantRoutes';
import blogRoutes from './blogRoutes';
import minigamesRoutes from './minigamesRoutes';
import personalRoutes from './personalRoutes';
import marketingRoutes from './marketingRoutes';
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
const HomeView = () => import('../views/HomeView.vue');
const routes = [
{
path: '/',
name: 'Home',
component: HomeView
component: HomeView,
meta: {
seo: {
title: 'YourPart - Community, Chat, Forum, Blogs, Vokabeltrainer und Spiele',
description: 'YourPart ist eine Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, dem Browser-Aufbauspiel Falukant und Minispielen.',
keywords: 'YourPart, Community, Chat, Forum, Blogs, Vokabeltrainer, Browsergame, Falukant, Minispiele',
canonicalPath: '/',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'YourPart',
url: buildAbsoluteUrl('/'),
inLanguage: 'de',
description: 'Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen.',
potentialAction: {
'@type': 'SearchAction',
target: `${buildAbsoluteUrl('/blogs')}?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
},
],
},
},
},
...marketingRoutes,
...authRoutes,
...socialRoutes,
...settingsRoutes,
@@ -45,4 +72,8 @@ router.beforeEach((to, from, next) => {
}
});
router.afterEach((to) => {
applyRouteSeo(to);
});
export default router;

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';
import ViewSettingsView from '../views/settings/ViewView.vue';
import FlirtSettingsView from '../views/settings/FlirtView.vue';
import SexualitySettingsView from '../views/settings/SexualityView.vue';
import AccountSettingsView from '../views/settings/AccountView.vue';
import InterestsView from '../views/settings/InterestsView.vue';
const PeronalSettingsView = () => import('../views/settings/PersonalView.vue');
const ViewSettingsView = () => import('../views/settings/ViewView.vue');
const FlirtSettingsView = () => import('../views/settings/FlirtView.vue');
const SexualitySettingsView = () => import('../views/settings/SexualityView.vue');
const AccountSettingsView = () => import('../views/settings/AccountView.vue');
const InterestsView = () => import('../views/settings/InterestsView.vue');
const settingsRoutes = [
{

View File

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

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
export function getChatWsUrl() {
// Prefer explicit env var
@@ -5,24 +7,7 @@ export function getChatWsUrl() {
if (override && typeof override === 'string' && override.trim()) {
return override.trim();
}
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL;
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) {
return envUrl.trim();
}
// Fallback: use current origin host with ws/wss and default port/path if provided by backend
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
const proto = isHttps ? 'wss' : 'ws';
// If a reverse proxy exposes the chat at a path, you can change '/chat' here.
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
// On localhost, prefer dedicated chat port 1235 by default
// Prefer IPv4 for localhost to avoid browsers resolving to ::1 (IPv6) where the server may not listen
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
return `${proto}://127.0.0.1:1235`;
}
// Default to same origin with chat port for production
const defaultUrl = `${proto}://${host}:1235`;
return defaultUrl;
return getChatWsUrlFromEnv();
}
// Provide a list of candidate WS URLs to try, in order of likelihood.
@@ -31,37 +16,8 @@ export function getChatWsCandidates() {
if (override && typeof override === 'string' && override.trim()) {
return [override.trim()];
}
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL;
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) {
return [envUrl.trim()];
}
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
const proto = isHttps ? 'wss' : 'ws';
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
const candidates = [];
// Common local setups: include IPv4 and IPv6 loopback variants (root only)
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
// Prefer IPv6 loopback first when available
const localHosts = ['[::1]', '127.0.0.1', 'localhost'];
for (const h of localHosts) {
const base = `${proto}://${h}:1235`;
candidates.push(base);
candidates.push(`${base}/`);
}
}
// Same-origin with chat port
const sameOriginBases = [`${proto}://${host}:1235`];
// If localhost-ish, also try 127.0.0.1 for chat port
if (host === 'localhost' || host === '::1' || host === '[::1]') {
sameOriginBases.push(`${proto}://[::1]:1235`);
sameOriginBases.push(`${proto}://127.0.0.1:1235`);
}
for (const base of sameOriginBases) {
candidates.push(base);
candidates.push(`${base}/`);
}
return candidates;
const resolved = getChatWsUrlFromEnv();
return [resolved, `${resolved}/`];
}
// Return optional subprotocols for the WebSocket handshake.

View File

@@ -4,6 +4,7 @@ import loadMenu from '../utils/menuLoader.js';
import router from '../router';
import apiClient from '../utils/axios.js';
import { io } from 'socket.io-client';
import { getDaemonSocketUrl, getSocketIoUrl } from '../utils/appConfig.js';
const store = createStore({
state: {
@@ -180,38 +181,7 @@ const store = createStore({
commit('setConnectionStatus', 'connecting');
// Socket.io URL für lokale Entwicklung und Produktion
let socketIoUrl = import.meta.env.VITE_SOCKET_IO_URL || import.meta.env.VITE_API_BASE_URL;
// Für lokale Entwicklung: direkte Backend-Verbindung
if (!socketIoUrl && (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')) {
socketIoUrl = 'http://localhost:3001';
}
// Direkte Verbindung zu Socket.io (ohne Apache-Proxy)
// In Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
const hostname = window.location.hostname;
const isProduction = hostname === 'www.your-part.de' || hostname.includes('your-part.de');
if (isProduction) {
// Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
socketIoUrl = `${protocol}//${hostname}:4443`;
} else {
// Lokale Entwicklung: direkte Backend-Verbindung
if (!socketIoUrl && (import.meta.env.DEV || hostname === 'localhost' || hostname === '127.0.0.1')) {
socketIoUrl = 'http://localhost:3001';
} else if (socketIoUrl) {
try {
const parsed = new URL(socketIoUrl, window.location.origin);
socketIoUrl = parsed.origin;
} catch (e) {
socketIoUrl = window.location.origin;
}
} else {
socketIoUrl = window.location.origin;
}
}
let socketIoUrl = getSocketIoUrl();
// Socket.io-Konfiguration: In Produktion mit HTTPS verwenden wir wss://
const socketOptions = {
@@ -287,29 +257,7 @@ const store = createStore({
// Daemon URL für lokale Entwicklung und Produktion
// Vite bindet Umgebungsvariablen zur Build-Zeit ein, daher Fallback-Logik basierend auf Hostname
const hostname = window.location.hostname;
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]';
const isProduction = hostname === 'www.your-part.de' || hostname.includes('your-part.de');
// Versuche Umgebungsvariable zu lesen (kann undefined sein, wenn nicht zur Build-Zeit gesetzt)
let daemonUrl = import.meta.env?.VITE_DAEMON_SOCKET;
console.log('[Daemon] Umgebungsvariable VITE_DAEMON_SOCKET:', daemonUrl);
console.log('[Daemon] DEV-Modus:', import.meta.env?.DEV);
console.log('[Daemon] Hostname:', hostname);
console.log('[Daemon] IsLocalhost:', isLocalhost);
console.log('[Daemon] IsProduction:', isProduction);
// Wenn Umgebungsvariable nicht gesetzt ist oder leer, verwende Fallback-Logik
if (!daemonUrl || (typeof daemonUrl === 'string' && daemonUrl.trim() === '')) {
// Immer direkte Verbindung zum Daemon-Port 4551 (verschlüsselt)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
daemonUrl = `${protocol}//${hostname}:4551/`;
console.log('[Daemon] Verwende direkte Verbindung zu Port 4551');
} else {
// Wenn Umgebungsvariable gesetzt ist, verwende sie direkt
console.log('[Daemon] Verwende Umgebungsvariable:', daemonUrl);
}
let daemonUrl = getDaemonSocketUrl();
console.log('[Daemon] Finale Daemon-URL:', daemonUrl);

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 store from '../store';
import { getApiBaseUrl } from './appConfig.js';
// API-Basis-URL - Apache-Proxy für Produktion, direkte Verbindung für lokale Entwicklung
const getApiBaseURL = () => {
// Wenn explizite Umgebungsvariable gesetzt ist, diese verwenden
if (import.meta.env.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL;
}
// Für lokale Entwicklung: direkte Backend-Verbindung
if (import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return 'http://localhost:3001';
}
// Für Produktion: Root-Pfad, da API-Endpunkte bereits mit /api beginnen
return '';
return getApiBaseUrl();
};

View File

@@ -1,12 +1,10 @@
// Centralized config for YourChat protocol mapping and WS endpoint
// Override via .env (VITE_* variables)
import { getChatWsUrlFromEnv } from './appConfig.js';
const env = import.meta.env || {};
export const CHAT_WS_URL = env.VITE_CHAT_WS_URL
|| (env.VITE_CHAT_WS_HOST || env.VITE_CHAT_WS_PORT
? `ws://${env.VITE_CHAT_WS_HOST || 'localhost'}:${env.VITE_CHAT_WS_PORT || '1235'}`
: (typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host + '/socket.io/');
export const CHAT_WS_URL = getChatWsUrlFromEnv();
// Event/type keys
export const CHAT_EVENT_KEY = env.VITE_CHAT_EVENT_KEY || 'type';

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>
<script>
import { defineAsyncComponent } from 'vue';
import { mapGetters } from 'vuex';
import HomeNoLoginView from './home/NoLoginView.vue';
import HomeLoggedInView from './home/LoggedInView.vue';
const HomeNoLoginView = defineAsyncComponent(() => import('./home/NoLoginView.vue'));
const HomeLoggedInView = defineAsyncComponent(() => import('./home/LoggedInView.vue'));
export default {
name: 'HomeView',

View File

@@ -15,7 +15,7 @@
<tbody>
<tr v-for="room in rooms" :key="room.id">
<td>{{ room.title }}</td>
<td>{{ room.roomTypeTr || room.roomTypeId }}</td>
<td>{{ getRoomTypeLabel(room) }}</td>
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
<td>
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
@@ -48,6 +48,14 @@ export default {
this.fetchRooms();
},
methods: {
getRoomTypeLabel(room) {
const roomTypeTr = room?.roomType?.tr || room?.roomTypeTr;
if (!roomTypeTr) {
return room?.roomTypeId ?? '-';
}
const translationKey = `admin.chatrooms.roomtype.${roomTypeTr}`;
return this.$te(translationKey) ? this.$t(translationKey) : roomTypeTr;
},
openCreateDialog() {
this.selectedRoom = null;
this.$refs.roomDialog.open();

View File

@@ -10,6 +10,36 @@
<!-- Match3 Levels Tab -->
<div v-if="activeTab === 'match3-levels'" class="match3-admin">
<section class="workflow-hero surface-card">
<div>
<span class="workflow-hero__eyebrow">Arbeitsfluss</span>
<h2>{{ $t('admin.match3.title') }}</h2>
<p>Erst Level waehlen, dann Spielfeld und Ziele anpassen und erst am Ende speichern.</p>
</div>
<div class="workflow-hero__meta">
<span class="workflow-pill">{{ currentModeLabel }}</span>
<span class="workflow-pill">{{ levels.length }} Level</span>
</div>
</section>
<section class="workflow-grid">
<article class="workflow-card surface-card">
<span class="workflow-card__step">1</span>
<h3>Level waehlen</h3>
<p>Bestehendes Level oeffnen oder sofort mit einer neuen Vorlage starten.</p>
</article>
<article class="workflow-card surface-card">
<span class="workflow-card__step">2</span>
<h3>Spielfeld bauen</h3>
<p>Groesse, Zuege, Kacheln und Layout zuerst festziehen, bevor Ziele folgen.</p>
</article>
<article class="workflow-card surface-card">
<span class="workflow-card__step">3</span>
<h3>Ziele speichern</h3>
<p>Objectives nur dann scharf stellen, wenn Grunddaten und Board bereits stimmen.</p>
</article>
</section>
<div class="section-header">
<h2>{{ $t('admin.match3.title') }}</h2>
</div>
@@ -31,13 +61,38 @@
{{ $t('admin.match3.levelFormat', { number: level.order, name: level.name }) }}
</option>
</select>
<button type="button" class="btn btn-secondary level-select-action" @click="createLevel">
{{ $t('admin.match3.newLevel') }}
</button>
</div>
<p class="level-selection__hint">
{{ isCreatingLevel ? 'Du erstellst gerade ein neues Level.' : 'Du bearbeitest ein bestehendes Level mit allen verbundenen Objectives.' }}
</p>
</div>
<section class="admin-summary-grid">
<article class="admin-summary-card surface-card">
<span class="admin-summary-card__label">Modus</span>
<strong>{{ currentModeLabel }}</strong>
<p>{{ selectedLevel ? selectedLevel.name : 'Neue Vorlage mit leerem Spielfeld' }}</p>
</article>
<article class="admin-summary-card surface-card">
<span class="admin-summary-card__label">Spielfeld</span>
<strong>{{ levelForm.boardWidth }} x {{ levelForm.boardHeight }}</strong>
<p>{{ levelForm.moveLimit }} Zuege, {{ levelForm.tileTypes.length }} aktive Tile-Typen.</p>
</article>
<article class="admin-summary-card surface-card">
<span class="admin-summary-card__label">Objectives</span>
<strong>{{ objectiveCount }}</strong>
<p>{{ objectiveCount ? 'Ziele vorhanden und bearbeitbar.' : 'Noch keine Zieldefinition hinterlegt.' }}</p>
</article>
</section>
<!-- Level Details -->
<div v-if="selectedLevelId !== 'new' && selectedLevel" class="level-details">
<div class="details-header">
<h3>{{ selectedLevel.name }}</h3>
<p>Bestehendes Level anpassen, ohne den Kontext des aktuellen Spielflusses zu verlieren.</p>
</div>
<div class="details-content">
<div class="form-group">
@@ -185,7 +240,7 @@
<button type="button" class="btn btn-danger" @click="deleteSelectedLevel">
{{ $t('admin.match3.delete') }}
</button>
<button type="button" class="btn btn-primary" @click="saveLevel">
<button type="button" class="btn btn-primary" :disabled="!isLevelFormValid" @click="saveLevel">
{{ $t('admin.match3.update') }}
</button>
</div>
@@ -539,7 +594,7 @@
<button type="button" class="btn btn-secondary" @click="cancelEdit">
{{ $t('admin.match3.cancel') }}
</button>
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary" :disabled="!isLevelFormValid">
{{ $t('admin.match3.create') }}
</button>
</div>
@@ -553,6 +608,7 @@
<script>
import SimpleTabs from '../../components/SimpleTabs.vue';
import apiClient from '../../utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'AdminMinigamesView',
@@ -593,10 +649,29 @@ export default {
gridTemplateRows: `repeat(${this.levelForm.boardHeight}, 1fr)`
};
},
isCreatingLevel() {
return this.selectedLevelId === 'new';
},
selectedLevel() {
if (this.selectedLevelId === 'new') return null;
return this.levels.find(l => l.id === this.selectedLevelId);
},
objectiveCount() {
return this.levelForm.objectives?.length || 0;
},
currentModeLabel() {
return this.isCreatingLevel ? 'Neues Level' : 'Level bearbeiten';
},
isLevelFormValid() {
return Boolean(
this.levelForm.name?.trim() &&
this.levelForm.description?.trim() &&
this.levelForm.boardWidth >= 3 &&
this.levelForm.boardHeight >= 3 &&
this.levelForm.moveLimit >= 5 &&
this.levelForm.order >= 1 &&
this.levelForm.tileTypes?.length
);
}
},
@@ -730,20 +805,14 @@ export default {
},
setTileType(index, tileType) {
console.log('setTileType called with:', index, tileType);
if (tileType === 'o') {
// Leer
this.boardMatrix[index] = { active: false, tileType: 'o', index: index };
} else if (tileType === 'r') {
// Zufällig
this.boardMatrix[index] = { active: true, tileType: 'r', index: index };
console.log('Set random tile at index:', index, this.boardMatrix[index]);
} else {
// Spezifischer Tile-Typ
this.boardMatrix[index] = { active: true, tileType: tileType, index: index };
}
this.selectedCellIndex = null; // Auswahl aufheben
console.log('Board matrix after update:', this.boardMatrix);
this.selectedCellIndex = null;
},
// Mapping für Tile-Typen zu Zeichen
@@ -785,7 +854,6 @@ export default {
objectives: []
};
this.updateBoardMatrix();
console.log('Bearbeitung abgebrochen, Objectives zurückgesetzt:', this.levelForm.objectives);
},
updateBoardMatrix() {
@@ -905,6 +973,7 @@ export default {
...this.levelForm,
boardLayout: this.generateBoardLayout()
};
const wasCreating = this.selectedLevelId === 'new';
let savedLevel;
if (this.selectedLevelId !== 'new') {
@@ -939,9 +1008,10 @@ export default {
this.selectedLevelId = 'new';
this.selectedCellIndex = null;
this.loadLevels();
showSuccess(this, wasCreating ? 'Level wurde erstellt.' : 'Level wurde aktualisiert.');
} catch (error) {
console.error('Fehler beim Speichern des Levels:', error);
alert('Fehler beim Speichern des Levels');
showError(this, 'Fehler beim Speichern des Levels');
}
},
@@ -950,8 +1020,10 @@ export default {
try {
await apiClient.delete(`/api/admin/minigames/match3/levels/${levelId}`);
this.loadLevels();
showSuccess(this, 'Level wurde geloescht.');
} catch (error) {
console.error('Fehler beim Löschen des Levels:', error);
showError(this, 'Fehler beim Loeschen des Levels');
}
}
},
@@ -1025,6 +1097,94 @@ export default {
padding: 20px;
}
.workflow-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
padding: 22px 24px;
margin-bottom: 16px;
}
.workflow-hero h2 {
margin: 0 0 8px;
}
.workflow-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.workflow-hero__eyebrow,
.admin-summary-card__label {
display: inline-flex;
margin-bottom: 4px;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.workflow-hero__meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.workflow-pill {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
color: #8a5411;
font-weight: 700;
}
.workflow-grid,
.admin-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.workflow-card,
.admin-summary-card {
padding: 18px;
}
.workflow-card__step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin-bottom: 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
color: #8a5411;
font-weight: 700;
}
.workflow-card h3,
.admin-summary-card strong {
margin: 0 0 8px;
}
.admin-summary-card strong {
display: block;
font-size: 1.2rem;
}
.workflow-card p,
.admin-summary-card p {
margin: 0;
color: var(--color-text-secondary);
}
.section-header {
margin-bottom: 30px;
text-align: center;
@@ -1055,6 +1215,8 @@ export default {
.level-dropdown {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.level-select {
@@ -1072,6 +1234,15 @@ export default {
border-color: #F9A22C;
}
.level-select-action {
min-width: 180px;
}
.level-selection__hint {
margin: 12px 0 0;
color: var(--color-text-secondary);
}
/* Level Details & Form */
.level-details,
.level-form {
@@ -1096,6 +1267,11 @@ export default {
margin: 0;
}
.details-header p {
margin: 8px 0 0;
color: var(--color-text-secondary);
}
.form-group {
margin-bottom: 20px;
}
@@ -1480,6 +1656,16 @@ export default {
padding: 15px;
}
.workflow-hero {
flex-direction: column;
align-items: flex-start;
}
.workflow-grid,
.admin-summary-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}

View File

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

View File

@@ -1,23 +1,35 @@
<template>
<div class="admin-users">
<h1>{{ $t('navigation.m-administration.useradministration') }}</h1>
<section class="admin-users__hero surface-card">
<span class="admin-users__eyebrow">Administration</span>
<h1>{{ $t('navigation.m-administration.useradministration') }}</h1>
<p>Benutzer suchen, Kerndaten anpassen und Sperrstatus direkt im System pflegen.</p>
</section>
<AdminUserSearch @select="select" />
<section class="admin-users__search surface-card">
<AdminUserSearch @select="select" />
</section>
<div v-if="selected" class="edit">
<h2>{{ selected.username }}</h2>
<label>
{{ $t('admin.user.name') }}
<section v-if="selected" class="edit surface-card">
<div class="edit__header">
<h2>{{ selected.username }}</h2>
<span class="edit__badge">{{ form.active ? 'Aktiv' : 'Gesperrt' }}</span>
</div>
<label class="edit__field">
<span>{{ $t('admin.user.name') }}</span>
<input v-model="form.username" type="text" />
</label>
<label>
{{ $t('admin.user.blocked') }}
<label class="edit__toggle">
<input type="checkbox" :checked="!form.active" @change="toggleBlocked($event)" />
<span>{{ $t('admin.user.blocked') }}</span>
</label>
<div class="actions">
<button @click="save">{{ $t('common.save') }}</button>
</div>
</div>
</section>
</div>
</template>
@@ -57,12 +69,105 @@ export default {
</script>
<style scoped>
.admin-users { padding: 20px; }
.results table { width: 100%; border-collapse: collapse; }
.results th, .results td { border: 1px solid #ddd; padding: 8px; }
.edit { margin-top: 16px; display: grid; gap: 10px; max-width: 480px; }
.actions { display: flex; gap: 8px; }
button { cursor: pointer; }
.admin-users {
display: grid;
gap: 18px;
}
.admin-users__hero,
.admin-users__search,
.edit {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.admin-users__hero,
.admin-users__search,
.edit {
padding: 22px 24px;
}
.admin-users__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.admin-users__hero p {
margin: 0;
color: var(--color-text-secondary);
}
.edit {
display: grid;
gap: 14px;
max-width: 560px;
}
.edit__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.edit__badge {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: var(--radius-pill);
background: var(--color-primary-soft);
color: var(--color-text-secondary);
font-weight: 700;
}
.edit__field {
display: grid;
gap: 8px;
}
.edit__field span {
font-weight: 600;
color: var(--color-text-secondary);
}
.edit__toggle {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--color-text-secondary);
}
.actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 760px) {
.admin-users__hero,
.admin-users__search,
.edit {
padding: 18px;
}
.edit__header {
align-items: flex-start;
flex-direction: column;
}
.actions button {
width: 100%;
}
}
</style>

View File

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

View File

@@ -1,29 +1,127 @@
<template>
<div class="blog-list">
<h1>Blogs</h1>
<div class="toolbar">
<router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link>
</div>
<div v-if="loading">Laden</div>
<div v-else>
<div v-if="!blogs.length">Keine Blogs gefunden.</div>
<ul>
<li v-for="b in blogs" :key="b.id">
<router-link :to="`/blogs/${b.id}`">{{ b.title }}</router-link>
<small> {{ b.owner?.username }}</small>
</li>
</ul>
<section class="blog-list__hero surface-card">
<div>
<span class="blog-list__kicker">Community-Blogs</span>
<h1>Blogs</h1>
<p>Artikel, Projektstaende und persoenliche Einblicke aus der YourPart-Community.</p>
</div>
<div class="toolbar">
<router-link v-if="$store.getters.isLoggedIn" class="btn" to="/blogs/create">Neuen Blog erstellen</router-link>
</div>
</section>
<div v-if="loading" class="blog-list__state surface-card">Laden</div>
<div v-else-if="!blogs.length" class="blog-list__state surface-card">Keine Blogs gefunden.</div>
<div v-else class="blog-grid">
<article v-for="b in blogs" :key="b.id" class="blog-card surface-card">
<div class="blog-card__meta">von {{ b.owner?.username || 'Unbekannt' }}</div>
<h2><router-link :to="blogUrl(b)">{{ b.title }}</router-link></h2>
<p>{{ blogExcerpt(b) }}</p>
<router-link class="blog-card__link" :to="blogUrl(b)">Zum Blog</router-link>
</article>
</div>
</div>
</template>
<script>
import { listBlogs } from '@/api/blogApi.js';
import { createBlogSlug } from '@/utils/seo.js';
export default {
name: 'BlogListView',
data: () => ({ blogs: [], loading: true }),
async mounted() {
try { this.blogs = await listBlogs(); } finally { this.loading = false; }
}
},
methods: {
blogUrl(blog) {
const slug = createBlogSlug(blog?.owner?.username, blog?.title);
return slug ? `/blogs/${encodeURIComponent(slug)}` : `/blogs/${blog.id}`;
},
blogExcerpt(blog) {
const source = blog?.description || 'Oeffentliche Eintraege, Gedanken und Projektstaende aus der Community.';
return source.length > 150 ? `${source.slice(0, 147)}...` : source;
},
},
}
</script>
<style scoped>
.blog-list {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.blog-list__hero {
display: flex;
align-items: end;
justify-content: space-between;
gap: 20px;
padding: 24px 26px;
margin-bottom: 18px;
}
.blog-list__kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(120, 195, 138, 0.14);
color: #42634e;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.blog-list__hero p {
margin: 0;
color: var(--color-text-secondary);
}
.blog-list__state {
padding: 26px;
text-align: center;
color: var(--color-text-secondary);
}
.blog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 18px;
}
.blog-card {
padding: 22px;
}
.blog-card__meta {
margin-bottom: 10px;
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 600;
}
.blog-card h2 {
margin-bottom: 10px;
font-size: 1.3rem;
}
.blog-card p {
margin-bottom: 16px;
color: var(--color-text-secondary);
}
.blog-card__link {
color: var(--color-primary);
font-weight: 700;
}
@media (max-width: 960px) {
.blog-list__hero {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -1,34 +1,43 @@
<template>
<div class="blog-view">
<div v-if="loading">Laden</div>
<div v-else>
<h1>{{ blog.title }}</h1>
<p v-if="blog.description">{{ blog.description }}</p>
<div class="meta">von {{ blog.owner?.username }}</div>
<div v-if="$store.getters.isLoggedIn" class="actions">
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
</div>
<div class="posts">
<h2>{{ $t('blog.posts') }}</h2>
<div v-if="!items.length">{{ $t('blog.noPosts') }}</div>
<article v-for="p in items" :key="p.id" class="post">
<h3>{{ p.title }}</h3>
<div class="content" v-html="sanitize(p.content)" />
</article>
<div class="pagination" v-if="total > pageSize">
<div v-if="loading" class="blog-view__state surface-card">Laden</div>
<div v-else-if="blog" class="blog-layout">
<section class="blog-hero surface-card">
<div>
<div class="meta">von {{ blog.owner?.username }}</div>
<h1>{{ blog.title }}</h1>
<p v-if="blog.description" class="blog-description">{{ blog.description }}</p>
</div>
<div v-if="$store.getters.isLoggedIn" class="actions">
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
</div>
</section>
<div class="blog-content">
<section class="posts surface-card">
<div class="posts__header">
<h2>{{ $t('blog.posts') }}</h2>
<span class="posts__count">{{ total }} Eintraege</span>
</div>
<div v-if="!items.length" class="blog-view__state">Keine Eintraege vorhanden.</div>
<article v-for="p in items" :key="p.id" class="post">
<h3>{{ p.title }}</h3>
<div class="content" v-html="sanitize(p.content)" />
</article>
<div class="pagination" v-if="total > pageSize">
<button :disabled="page===1" @click="go(page-1)">«</button>
<span>{{ page }} / {{ pages }}</span>
<button :disabled="page===pages" @click="go(page+1)">»</button>
</div>
</section>
<div v-if="isOwner" class="post-editor surface-card">
<h3>{{ $t('blog.newPost') }}</h3>
<form @submit.prevent="addPost">
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
<RichTextEditor v-model="newPost.content" :blog-id="blog.id" />
<button class="btn" type="submit">{{ $t('blog.publish') }}</button>
</form>
</div>
</div>
<div v-if="isOwner" class="post-editor">
<h3>{{ $t('blog.newPost') }}</h3>
<form @submit.prevent="addPost">
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
<RichTextEditor v-model="newPost.content" :blog-id="blog.id" />
<button class="btn" type="submit">{{ $t('blog.publish') }}</button>
</form>
</div>
</div>
</div>
</template>
@@ -37,6 +46,7 @@
import { getBlog, listPosts, createPost } from '@/api/blogApi.js';
import DOMPurify from 'dompurify';
import RichTextEditor from './components/RichTextEditor.vue';
import { applySeo, buildAbsoluteUrl, createBlogSlug, stripHtml, truncateText } from '@/utils/seo.js';
export default {
name: 'BlogView',
props: { id: String, slug: String },
@@ -46,9 +56,73 @@ export default {
isOwner() {
const u = this.$store.getters.user;
return !!(u && this.blog && this.blog.owner && this.blog.owner.hashedId === u.id);
}
},
pages() {
return Math.max(1, Math.ceil(this.total / this.pageSize));
},
},
async mounted() {
await this.loadBlog();
},
watch: {
'$route.fullPath': {
async handler() {
await this.loadBlog();
},
},
},
methods: {
canonicalBlogPath() {
const slug = createBlogSlug(this.blog?.owner?.username, this.blog?.title);
if (slug) {
return `/blogs/${encodeURIComponent(slug)}`;
}
return this.blog?.id ? `/blogs/${this.blog.id}` : '/blogs';
},
applyBlogSeo() {
if (!this.blog) {
return;
}
const plainTextPosts = this.items
.map((item) => `${item.title || ''} ${stripHtml(item.content || '')}`.trim())
.filter(Boolean)
.join(' ');
const summarySource = this.blog.description || plainTextPosts || 'Oeffentlicher Community-Blog auf YourPart.';
const description = truncateText(summarySource, 160);
const canonicalPath = this.canonicalBlogPath();
applySeo({
title: `${this.blog.title} | Blog auf YourPart`,
description,
canonicalPath,
keywords: `Blog, ${this.blog.title}, ${this.blog.owner?.username || 'YourPart'}, Community`,
type: 'article',
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'Blog',
name: this.blog.title,
description,
url: buildAbsoluteUrl(canonicalPath),
inLanguage: 'de',
author: this.blog.owner?.username
? {
'@type': 'Person',
name: this.blog.owner.username,
}
: undefined,
},
],
});
},
async loadBlog() {
this.loading = true;
this.blog = null;
this.items = [];
this.resolvedId = null;
try {
let id = this.$route.params.id;
// If we have a slug route param or the id is non-numeric, resolve to id
@@ -67,12 +141,18 @@ export default {
const useId = id || this.resolvedId;
this.blog = await getBlog(useId);
await this.fetchPage(1);
this.applyBlogSeo();
} catch (e) {
console.log(e);
// this.$router.replace('/blogs');
applySeo({
title: 'Blog nicht gefunden | YourPart',
description: 'Der angeforderte Blog konnte nicht geladen werden.',
canonicalPath: '/blogs',
robots: 'noindex, nofollow',
});
} finally { this.loading = false; }
},
methods: {
},
sanitize(html) {
return DOMPurify.sanitize(html || '');
},
@@ -83,8 +163,8 @@ export default {
this.page = res.page;
this.pageSize = res.pageSize;
this.total = res.total;
this.applyBlogSeo();
},
get pages() { return Math.max(1, Math.ceil(this.total / this.pageSize)); },
async go(p) { if (p>=1 && p<=this.pages) await this.fetchPage(p); },
async addPost() {
if (!this.newPost.title || !this.newPost.content) return;
@@ -98,12 +178,109 @@ export default {
</script>
<style lang="scss" scoped>
.blog-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.blog-layout {
display: grid;
gap: 18px;
}
.blog-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
padding: 26px;
}
.blog-description {
margin: 0;
max-width: 70ch;
color: var(--color-text-secondary);
}
.meta {
margin-bottom: 10px;
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.blog-content {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 0.95fr);
gap: 18px;
align-items: start;
}
.posts,
.post-editor {
padding: 24px;
}
.posts__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.posts__count {
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 700;
}
.post + .post {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--color-border);
}
.content {
color: var(--color-text-secondary);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
.blog-view__state {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.editbutton {
border: 1px solid #000;
background-color: #f9a22c;
margin-bottom: 1em;
border-radius: 3px;
padding: 0.2em 0.5em;
margin-bottom: 0;
display: inline-block;
}
@media (max-width: 960px) {
.blog-hero,
.blog-content {
grid-template-columns: 1fr;
display: grid;
}
.blog-hero {
padding: 20px;
}
.posts,
.post-editor {
padding: 18px;
}
}
</style>

View File

@@ -2,7 +2,14 @@
<div class="contenthidden">
<StatusBar ref="statusBar" />
<div class="contentscroll">
<h2>{{ $t('falukant.branch.title') }}</h2>
<div class="falukant-branch">
<section class="branch-hero surface-card">
<div>
<span class="branch-kicker">Niederlassung</span>
<h2>{{ $t('falukant.branch.title') }}</h2>
<p>Produktion, Lager, Verkauf und Transport in einer spielweltbezogenen Steuerflaeche.</p>
</div>
</section>
<BranchSelection
:branches="branches"
@@ -308,6 +315,7 @@
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -360,6 +368,7 @@ export default {
vehicles: [],
activeTab: 'production',
productPricesCache: {}, // Cache für regionale Preise: { productId: price }
productPricesCacheRegionId: null, // regionId, für die der Cache gültig ist
tabs: [
{ value: 'production', label: 'falukant.branch.tabs.production' },
{ value: 'inventory', label: 'falukant.branch.tabs.inventory' },
@@ -569,30 +578,46 @@ export default {
async loadProductPricesForCurrentBranch() {
if (!this.selectedBranch || !this.selectedBranch.regionId) {
this.productPricesCache = {};
this.productPricesCacheRegionId = null;
return;
}
// Lade Preise für alle Produkte in der aktuellen Region
const prices = {};
for (const product of this.products) {
try {
const { data } = await apiClient.get('/api/falukant/products/price-in-region', {
params: {
productId: product.id,
regionId: this.selectedBranch.regionId
}
});
prices[product.id] = data.price;
} catch (error) {
console.error(`Error loading price for product ${product.id}:`, error);
// Fallback auf Standard-Berechnung
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.6;
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
}
if (this.productPricesCacheRegionId === this.selectedBranch.regionId && Object.keys(this.productPricesCache).length > 0) {
return;
}
try {
const { data } = await apiClient.get('/api/falukant/products/prices-in-region', {
params: {
regionId: this.selectedBranch.regionId
}
});
this.productPricesCache = data.prices || {};
this.productPricesCacheRegionId = this.selectedBranch.regionId;
} catch (error) {
console.error(`Error loading product prices for region ${this.selectedBranch.regionId}:`, error);
// Fallback: Lade Preise einzeln (alte Methode)
console.warn('[BranchView] Falling back to individual product price requests');
const prices = {};
for (const product of this.products) {
try {
const { data } = await apiClient.get('/api/falukant/products/price-in-region', {
params: {
productId: product.id,
regionId: this.selectedBranch.regionId
}
});
prices[product.id] = data.price;
} catch (err) {
console.error(`Error loading price for product ${product.id}:`, err);
// Fallback auf Standard-Berechnung
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.6;
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
}
}
this.productPricesCache = prices;
this.productPricesCacheRegionId = this.selectedBranch?.regionId ?? null;
}
this.productPricesCache = prices;
},
formatPercent(value) {
@@ -704,13 +729,17 @@ export default {
},
speedLabel(value) {
// Expect numeric speeds 1..4; provide localized labels as fallback to raw value
const key = value == null ? 'unknown' : String(value);
if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—';
if (typeof value === 'object') {
const k = value.tr ?? value.id ?? 'unknown';
const tKey = `falukant.branch.transport.speed.${k}`;
const t = this.$t(tKey);
return (t && t !== tKey) ? t : String(k);
}
const key = String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
// If translation returns the key (no translation found), fall back to the numeric value
if (!translated || translated === tKey) return value;
return translated;
return (!translated || translated === tKey) ? key : translated;
},
transportModeLabel(mode) {
@@ -1089,8 +1118,49 @@ export default {
</script>
<style scoped lang="scss">
h2 {
padding-top: 20px;
.falukant-branch {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.branch-hero {
padding: 24px 26px;
margin-bottom: 16px;
background:
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
}
.branch-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.branch-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.branch-tab-content {
margin-top: 16px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 252, 247, 0.86);
box-shadow: var(--shadow-soft);
}
.branch-tab-pane {
min-height: 0;
}
.send-all-vehicles {
@@ -1140,11 +1210,12 @@ h2 {
}
.modal-content {
background: white;
background: rgba(255,255,255,0.98);
padding: 2rem;
border-radius: 8px;
border-radius: var(--radius-lg);
min-width: 400px;
max-width: 600px;
box-shadow: var(--shadow-medium);
}
.send-vehicle-form {

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