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.

This commit is contained in:
Torsten Schulz (local)
2026-03-18 22:45:22 +01:00
parent 59869e077e
commit 4442937ebd
29 changed files with 1226 additions and 396 deletions

View File

@@ -36,6 +36,11 @@ 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 corsOrigins = (process.env.CORS_ORIGINS || process.env.FRONTEND_URL || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
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 +56,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 (corsOrigins.length === 0 || corsOrigins.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

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

@@ -3,7 +3,7 @@ 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 {
@@ -13,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 });
});
});
@@ -118,7 +144,7 @@ class ChatService {
history: [messageBundle],
});
}
if (this.channel) {
if (this.channel && this.amqpAvailable) {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
}
}

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 {

View File

@@ -2,38 +2,59 @@
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';
export function setupWebSocket(server) {
const io = new Server(server);
let channel = null;
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());
io.emit('newMessage', message);
}, { noAck: true });
});
});
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 });
socket.on('newMessage', (message) => {
if (channel) {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
return;
}
// Empfangt eine Nachricht vom WebSocket-Client und sendet sie an die RabbitMQ-Warteschlange
socket.on('newMessage', (message) => {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
});
io.emit('newMessage', message);
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
}

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 {

200
docs/UI_REDESIGN_PLAN.md Normal file
View File

@@ -0,0 +1,200 @@
# UI Redesign Plan
## 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.
### Phase 4: Tiefere Produktbereiche
- Sekundaere Ansichten und Admin-Bereiche nachziehen.
- Visuelle Altlasten in Randbereichen bereinigen.
### Phase 5: QA und Verfeinerung
- Responsive Review.
- Accessibility Review.
- Performance-Pruefung auf unnötige visuelle Last.
- Konsistenz-Check ueber das gesamte Produkt.
## 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.

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_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,25 +8,25 @@
<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="https://www.your-part.de/images/logos/logo.png" />
<meta property="og:image" content="%VITE_PUBLIC_BASE_URL%/images/logos/logo.png" />
<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="https://www.your-part.de/images/logos/logo.png" />
<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="https://www.your-part.de/" />
<link rel="alternate" hreflang="x-default" href="https://www.your-part.de/" />
<link rel="alternate" hreflang="de" href="%VITE_PUBLIC_BASE_URL%/" />
<link rel="alternate" hreflang="x-default" href="%VITE_PUBLIC_BASE_URL%/" />
</head>

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" />
@@ -71,10 +71,10 @@ export default {
</script>
<style>
#app {
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
height: 100vh;
overflow: hidden;
}
</style>

View File

@@ -1,50 +1,282 @@
: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: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--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: 42px;
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);
color: var(--color-text-secondary);
border: 1px solid var(--color-text-secondary);
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:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a: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;
}
.surface-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.link {
color: var(--color-primary);
cursor: pointer;
}
.link:hover {
color: var(--color-primary-hover);
}
.rc-system {
@@ -52,25 +284,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 +300,42 @@ 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;
}
.contentscroll {
width: 100%;
height: 100%;
overflow: auto;
@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);
}
}

View File

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

View File

@@ -1,18 +1,23 @@
<template>
<footer>
<div class="logo" @click="showFalukantDaemonStatus"><img src="/images/icons/logo_color.png"></div>
<div class="window-bar">
<footer class="app-footer">
<div class="app-footer__inner">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
</button>
<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>
</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 +68,47 @@ 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-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;
}
.window-bar {
@@ -83,24 +117,25 @@ footer {
align-items: center;
justify-content: flex-start;
gap: 10px;
padding-left: 10px;
overflow: auto;
}
.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 +146,35 @@ 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;
}
.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;
}
.window-bar,
.static-block {
width: 100%;
}
.static-block {
justify-content: space-between;
gap: 12px;
}
}
</style>

View File

@@ -1,15 +1,25 @@
<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, Spiele und Lernen auf einer 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-pill">Beta</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 +53,107 @@ export default {
</script>
<style scoped>
header {
.app-header {
position: relative;
flex: 0 0 auto;
padding: 8px 14px;
background:
linear-gradient(180deg, rgba(255, 248, 236, 0.94) 0%, rgba(247, 235, 216, 0.98) 100%);
color: #2b1f14;
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: 0 6px 18px rgba(93, 64, 55, 0.08);
}
.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;
}
.advertisement {
flex: 1;
.logo {
width: 42px;
height: 42px;
padding: 6px;
border-radius: 14px;
background:
linear-gradient(180deg, rgba(248, 162, 43, 0.2) 0%, rgba(255, 255, 255, 0.7) 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: 1px;
}
.brand-copy strong {
font-size: 1.05rem;
line-height: 1.1;
color: #3a2a1b;
}
.brand-copy span {
font-size: 0.8rem;
color: rgba(95, 75, 57, 0.88);
}
.header-meta {
display: flex;
align-items: center;
gap: 12px;
}
.header-pill {
padding: 5px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
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 +174,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 +198,24 @@ header {
50% { opacity: 0.5; }
100% { opacity: 1; }
}
@media (max-width: 960px) {
.app-header {
padding: 6px 10px;
}
.app-header__inner {
gap: 8px;
flex-wrap: wrap;
}
.header-meta {
justify-content: space-between;
flex-wrap: wrap;
}
.brand-copy span {
font-size: 0.76rem;
}
}
</style>

View File

@@ -15,8 +15,8 @@
>&nbsp;</span>
<span>{{ $t(`navigation.${key}`) }}</span>
<!-- Untermenü Ebene 1 -->
<ul v-if="item.children" class="submenu1">
<!-- Untermenü Ebene 1 -->
<ul v-if="hasTopLevelSubmenu(item)" class="submenu1">
<li
v-for="(subitem, subkey) in item.children"
:key="subkey"
@@ -29,7 +29,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>
@@ -183,6 +183,34 @@ export default {
methods: {
...mapActions(['loadMenu', 'logout']),
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);
},
openMultiChat() {
// Räume können später dynamisch geladen werden, hier als Platzhalter ein Beispiel:
const exampleRooms = [
@@ -254,7 +282,7 @@ export default {
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 (this.hasChildren(item)) return;
// 2) view → Dialog/Window
if (item.view) {
@@ -295,8 +323,6 @@ nav,
nav > ul {
display: flex;
justify-content: space-between;
background-color: #f8a22b;
color: #000;
padding: 0;
margin: 0;
cursor: pointer;
@@ -304,6 +330,28 @@ nav > ul {
z-index: 999;
}
nav {
max-width: var(--shell-max-width);
margin: 0 auto;
align-items: stretch;
gap: 10px;
padding: 6px 12px;
border-radius: 0;
background: var(--color-primary-orange-light);
border-top: 1px solid rgba(93, 64, 55, 0.08);
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: none;
color: var(--color-text-primary);
}
nav > ul {
flex: 1;
align-items: center;
gap: 6px;
background: transparent;
flex-wrap: wrap;
}
ul {
list-style-type: none;
padding: 0;
@@ -311,18 +359,23 @@ ul {
}
nav > ul > li {
padding: 0 1em;
line-height: 2.5em;
transition: background-color 0.25s;
display: flex;
align-items: center;
min-height: 36px;
padding: 0 12px;
line-height: 1;
border-radius: 999px;
transition: background-color 0.25s, color 0.25s, transform 0.2s;
}
nav > ul > li:hover {
background-color: #f8a22b;
background-color: rgba(248, 162, 43, 0.16);
white-space: nowrap;
transform: translateY(-1px);
}
nav > ul > li:hover > span {
color: #000;
color: var(--color-primary);
}
nav > ul > li:hover > ul {
@@ -335,17 +388,22 @@ a {
.right-block {
display: flex;
gap: 10px;
align-items: center;
gap: 12px;
padding-left: 8px;
}
.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 +411,29 @@ a {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
padding-left: 24px;
width: 40px;
height: 40px;
text-align: left;
border-radius: 999px;
background-color: rgba(120, 195, 138, 0.12);
border: 1px solid rgba(93, 64, 55, 0.1);
}
.mainmenuitem {
position: relative;
font-weight: 700;
}
.submenu1 {
position: absolute;
border: 1px solid #5D4037;
background-color: #f8a22b;
border: 1px solid rgba(93, 64, 55, 0.12);
background: rgba(255, 252, 247, 0.98);
left: 0;
top: 2.5em;
top: calc(100% + 10px);
min-width: 220px;
padding: 8px;
border-radius: 18px;
box-shadow: 0 10px 18px rgba(93, 64, 55, 0.12);
max-height: 0;
overflow: visible;
opacity: 0;
@@ -386,15 +453,16 @@ a {
}
.submenu1 > li {
padding: 0.5em;
padding: 0.75em 0.9em;
line-height: 1em;
color: #5D4037;
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 +475,7 @@ a {
.menu-icon {
width: 24px;
height: 24px;
margin-right: 3px;
margin-right: 8px;
}
.submenu-icon {
@@ -419,10 +487,14 @@ a {
.submenu2 {
position: absolute;
background-color: #f8a22b;
left: 100%;
background: rgba(255, 252, 247, 0.98);
left: calc(100% + 8px);
top: 0;
border: 1px solid #5D4037;
min-width: 220px;
padding: 8px;
border-radius: 18px;
border: 1px solid rgba(71, 52, 35, 0.12);
box-shadow: 0 10px 18px rgba(93, 64, 55, 0.12);
max-height: 0;
overflow: hidden;
opacity: 0;
@@ -442,14 +514,15 @@ a {
}
.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);
}
.subsubmenu {
@@ -457,4 +530,37 @@ a {
font-size: 8pt;
margin-right: -4px;
}
.username {
font-weight: 800;
}
@media (max-width: 960px) {
nav {
margin: 0;
flex-direction: column;
padding: 8px 10px;
}
nav > ul,
.right-block {
width: 100%;
}
.right-block {
justify-content: space-between;
padding-left: 0;
}
.logoutblock {
align-items: flex-start;
}
.submenu1,
.submenu2 {
position: static;
min-width: 100%;
margin-top: 8px;
}
}
</style>

View File

@@ -1,5 +1,13 @@
<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>
@@ -41,7 +49,8 @@ export default {
animationId: null,
mixer: null,
clock: markRaw(new THREE.Clock()),
baseYPosition: 0 // Basis-Y-Position für Animation
baseYPosition: 0,
showFallback: false
};
},
computed: {
@@ -93,6 +102,11 @@ 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: {
@@ -115,6 +129,7 @@ export default {
init3D() {
const container = this.$refs.container;
if (!container) return;
this.showFallback = false;
// Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
this.scene = markRaw(new THREE.Scene());
@@ -301,6 +316,7 @@ export default {
}
} catch (error) {
console.error('Error loading 3D model:', error);
this.showFallback = true;
}
},
@@ -375,10 +391,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

@@ -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>
@@ -220,13 +228,18 @@ export default {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
background-color: #f4f4f4;
border: 1px solid #ccc;
border-radius: 4px;
width: calc(100% + 40px);
box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 1.2em;
margin: -21px -20px 1.5em -20px;
position: fixed;
padding: 0.4rem 0.75rem;
margin: 0 0 1.5em 0;
position: sticky;
top: 0;
z-index: 100;
}
@@ -237,6 +250,14 @@ export default {
align-items: center;
}
.quick-access {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.2rem;
}
.status-icon-wrapper {
display: inline-flex;
align-items: center;
@@ -254,6 +275,8 @@ export default {
.menu-icon {
width: 30px;
height: 30px;
display: block;
flex: 0 0 auto;
cursor: pointer;
padding: 4px 2px 0 0;
}

View File

@@ -1,6 +1,7 @@
import BlogListView from '@/views/blog/BlogListView.vue';
import BlogView from '@/views/blog/BlogView.vue';
import BlogEditorView from '@/views/blog/BlogEditorView.vue';
import { buildAbsoluteUrl } from '@/utils/seo.js';
export default [
{ path: '/blogs/create', name: 'BlogCreate', component: BlogEditorView, meta: { requiresAuth: true } },
@@ -46,7 +47,7 @@ export default [
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Blogs auf YourPart',
url: 'https://www.your-part.de/blogs',
url: buildAbsoluteUrl('/blogs'),
description: 'Oeffentliche Blogs und Community-Beitraege auf YourPart.',
inLanguage: 'de',
},

View File

@@ -10,7 +10,7 @@ import blogRoutes from './blogRoutes';
import minigamesRoutes from './minigamesRoutes';
import personalRoutes from './personalRoutes';
import marketingRoutes from './marketingRoutes';
import { applyRouteSeo } from '../utils/seo';
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
const routes = [
{
@@ -28,12 +28,12 @@ const routes = [
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'YourPart',
url: 'https://www.your-part.de/',
url: buildAbsoluteUrl('/'),
inLanguage: 'de',
description: 'Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen.',
potentialAction: {
'@type': 'SearchAction',
target: 'https://www.your-part.de/blogs?q={search_term_string}',
target: `${buildAbsoluteUrl('/blogs')}?q={search_term_string}`,
'query-input': 'required name=search_term_string',
},
},

View File

@@ -1,3 +1,5 @@
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');
@@ -18,7 +20,7 @@ const marketingRoutes = [
'@context': 'https://schema.org',
'@type': 'VideoGame',
name: 'Falukant',
url: 'https://www.your-part.de/falukant',
url: buildAbsoluteUrl('/falukant'),
description: 'Mittelalterliches Browser-Aufbauspiel mit Handel, Politik, Familie und Charakterentwicklung.',
gamePlatform: 'Web Browser',
applicationCategory: 'Game',
@@ -47,7 +49,7 @@ const marketingRoutes = [
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'YourPart Minispiele',
url: 'https://www.your-part.de/minigames',
url: buildAbsoluteUrl('/minigames'),
description: 'Browser-Minispiele auf YourPart mit Match 3 und Taxi.',
inLanguage: 'de',
},
@@ -70,7 +72,7 @@ const marketingRoutes = [
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'YourPart Vokabeltrainer',
url: 'https://www.your-part.de/vokabeltrainer',
url: buildAbsoluteUrl('/vokabeltrainer'),
description: 'Interaktiver Vokabeltrainer mit Kursen, Lektionen und Übungen zum Sprachenlernen.',
applicationCategory: 'EducationalApplication',
operatingSystem: 'Web',

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

@@ -1,3 +1,5 @@
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';
@@ -21,7 +23,7 @@ const MANAGED_META_KEYS = [
];
function getBaseUrl() {
return (import.meta.env.VITE_PUBLIC_BASE_URL || DEFAULT_BASE_URL).replace(/\/$/, '');
return getPublicBaseUrl().replace(/\/$/, '') || DEFAULT_BASE_URL;
}
function upsertMeta(attr, key, content) {

View File

@@ -51,8 +51,11 @@
:title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin"
ref="passwordInput">
</div>
<div>
<label><input type="checkbox"><span>{{ $t('home.nologin.login.stayLoggedIn') }}</span></label>
<div class="stay-logged-in-row">
<label class="stay-logged-in-label">
<input class="stay-logged-in-checkbox" type="checkbox">
<span>{{ $t('home.nologin.login.stayLoggedIn') }}</span>
</label>
</div>
</div>
<div>
@@ -125,7 +128,8 @@ export default {
const response = await apiClient.post('/api/auth/login', { username: this.username, password: this.password });
this.login(response.data);
} catch (error) {
this.$root.$refs.errorDialog.open(`tr:error.${error.response.data.error}`);
const errorKey = error?.response?.data?.error || 'network';
this.$root.$refs.errorDialog.open(`tr:error.${errorKey}`);
}
}
}
@@ -146,34 +150,45 @@ export default {
display: flex;
align-items: stretch;
justify-content: center;
overflow: hidden;
gap: 2em;
width: 100%;
height: 100%;
flex: 1;
min-height: 0;
}
.home-structure>div {
flex: 1;
text-align: center;
display: flex;
min-height: 0;
}
.mascot {
flex: 0 0 clamp(180px, 22%, 280px);
justify-content: center;
align-items: center;
background-color: #fdf1db;
width: 80%;
height: 80%;
min-height: 400px;
align-items: stretch;
background: linear-gradient(180deg, #fff5e8 0%, #fce7ca 100%);
border: 1px solid rgba(248, 162, 43, 0.16);
border-radius: 20px;
box-shadow: 0 10px 24px rgba(93, 64, 55, 0.08);
overflow: hidden;
align-self: center;
height: clamp(320px, 68vh, 560px);
min-height: 320px;
max-height: 560px;
}
.actions {
display: flex;
flex-direction: column;
gap: 2em;
flex: 1 1 auto;
min-height: 0;
}
.actions>div {
flex: 1;
min-height: 0;
background-color: #FFF4F0;
align-items: center;
justify-content: flex-start;
@@ -188,6 +203,33 @@ export default {
color: var(--color-primary-orange);
}
.stay-logged-in-row {
width: 100%;
display: flex;
justify-content: flex-start;
margin-top: 0.35rem;
}
.stay-logged-in-label {
display: inline-flex;
align-items: center;
gap: 0.55rem;
cursor: pointer;
font-size: 0.95rem;
}
.stay-logged-in-checkbox {
width: 16px;
min-width: 16px;
height: 16px;
min-height: 16px;
margin: 0;
padding: 0;
flex: 0 0 16px;
accent-color: var(--color-primary-orange);
box-shadow: none;
}
.seo-content {
max-width: 1000px;
margin: 24px auto 0 auto;
@@ -233,7 +275,32 @@ export default {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
@media (max-width: 960px) {
.home-structure {
flex-direction: column;
gap: 1rem;
overflow: auto;
}
.mascot {
min-height: 260px;
height: 260px;
flex: 0 0 260px;
}
.actions {
min-height: auto;
}
.actions>div {
min-height: 260px;
}
}
</style>