diff --git a/backend/app.js b/backend/app.js index 66cc315..6f565fe 100644 --- a/backend/app.js +++ b/backend/app.js @@ -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); diff --git a/backend/server.js b/backend/server.js index 43eca2d..e1de61e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 => { diff --git a/backend/services/chatService.js b/backend/services/chatService.js index 393c96a..5d6c59b 100644 --- a/backend/services/chatService.js +++ b/backend/services/chatService.js @@ -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))); } } diff --git a/backend/services/chatTcpBridge.js b/backend/services/chatTcpBridge.js index 85f9448..e921bae 100644 --- a/backend/services/chatTcpBridge.js +++ b/backend/services/chatTcpBridge.js @@ -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 { diff --git a/backend/services/webSocketService.js b/backend/services/webSocketService.js index 38bd600..636b864 100644 --- a/backend/services/webSocketService.js +++ b/backend/services/webSocketService.js @@ -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'); }); }); } diff --git a/backend/utils/socket.js b/backend/utils/socket.js index 8c8e956..d62a70b 100644 --- a/backend/utils/socket.js +++ b/backend/utils/socket.js @@ -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 { @@ -80,4 +88,4 @@ export async function notifyAllUsers(event, data) { } catch (err) { console.error('Fehler beim Senden der Benachrichtigung an alle Benutzer:', err); } -} \ No newline at end of file +} diff --git a/docs/UI_REDESIGN_PLAN.md b/docs/UI_REDESIGN_PLAN.md new file mode 100644 index 0000000..8a9c36d --- /dev/null +++ b/docs/UI_REDESIGN_PLAN.md @@ -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. diff --git a/frontend/.env.local b/frontend/.env.local index 321fcc3..78ddd00 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -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 diff --git a/frontend/.env.production b/frontend/.env.production index 0aa64f6..af17443 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -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 diff --git a/frontend/.env.server b/frontend/.env.server index ced11bc..af17443 100644 --- a/frontend/.env.server +++ b/frontend/.env.server @@ -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 - diff --git a/frontend/index.html b/frontend/index.html index 13dd7d0..38747f1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,25 +8,25 @@ - + - + - + - + - - + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d0b0497..e999ee6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,8 +1,8 @@ - - .contentscroll { - padding: 20px; - } - - \ No newline at end of file diff --git a/frontend/src/components/AppFooter.vue b/frontend/src/components/AppFooter.vue index 047f60d..07df752 100644 --- a/frontend/src/components/AppFooter.vue +++ b/frontend/src/components/AppFooter.vue @@ -1,18 +1,23 @@ @@ -63,18 +68,47 @@ export default { \ No newline at end of file + +.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; + } +} + diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 45457b0..4907566 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,15 +1,25 @@ - +
- +
@@ -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; } diff --git a/frontend/src/router/blogRoutes.js b/frontend/src/router/blogRoutes.js index 428a8ee..288ab22 100644 --- a/frontend/src/router/blogRoutes.js +++ b/frontend/src/router/blogRoutes.js @@ -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', }, diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 0937f83..49e3d47 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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', }, }, diff --git a/frontend/src/router/marketingRoutes.js b/frontend/src/router/marketingRoutes.js index c8c98e8..f100816 100644 --- a/frontend/src/router/marketingRoutes.js +++ b/frontend/src/router/marketingRoutes.js @@ -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', diff --git a/frontend/src/services/chatWs.js b/frontend/src/services/chatWs.js index 344ca5d..f88fbe6 100644 --- a/frontend/src/services/chatWs.js +++ b/frontend/src/services/chatWs.js @@ -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. @@ -84,4 +40,4 @@ export function getChatWsProtocols() { // Default to the 'chat' subprotocol so the server can gate connections accordingly return ['chat']; } - \ No newline at end of file + diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index 463ca9f..4b087fd 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -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); diff --git a/frontend/src/utils/appConfig.js b/frontend/src/utils/appConfig.js new file mode 100644 index 0000000..aff3f8e --- /dev/null +++ b/frontend/src/utils/appConfig.js @@ -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()); +} diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js index a9e9032..dbb461a 100644 --- a/frontend/src/utils/axios.js +++ b/frontend/src/utils/axios.js @@ -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(); }; diff --git a/frontend/src/utils/chatConfig.js b/frontend/src/utils/chatConfig.js index a2f837c..26ecf0a 100644 --- a/frontend/src/utils/chatConfig.js +++ b/frontend/src/utils/chatConfig.js @@ -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'; diff --git a/frontend/src/utils/seo.js b/frontend/src/utils/seo.js index 9bae095..0a9d9cb 100644 --- a/frontend/src/utils/seo.js +++ b/frontend/src/utils/seo.js @@ -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) { diff --git a/frontend/src/views/home/NoLoginView.vue b/frontend/src/views/home/NoLoginView.vue index 399e158..78fc7e8 100644 --- a/frontend/src/views/home/NoLoginView.vue +++ b/frontend/src/views/home/NoLoginView.vue @@ -51,8 +51,11 @@ :title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin" ref="passwordInput"> -
- +
+
@@ -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; + } }