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

View File

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

View File

@@ -3,7 +3,7 @@ import amqp from 'amqplib/callback_api.js';
import User from '../models/community/user.js'; import User from '../models/community/user.js';
import Room from '../models/chat/room.js'; import Room from '../models/chat/room.js';
const RABBITMQ_URL = 'amqp://localhost'; const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'oneToOne_messages'; const QUEUE = 'oneToOne_messages';
class ChatService { class ChatService {
@@ -13,11 +13,37 @@ class ChatService {
this.users = []; this.users = [];
this.randomChats = []; this.randomChats = [];
this.oneToOneChats = []; this.oneToOneChats = [];
this.channel = null;
this.amqpAvailable = false;
this.initRabbitMq();
}
initRabbitMq() {
amqp.connect(RABBITMQ_URL, (err, connection) => { amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) throw err; if (err) {
connection.createChannel((err, channel) => { console.warn(`[chatService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - fallback ohne Queue wird verwendet.`);
if (err) throw err; return;
}
connection.on('error', (connectionError) => {
console.warn('[chatService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
this.channel = null;
this.amqpAvailable = false;
});
connection.on('close', () => {
console.warn('[chatService] RabbitMQ-Verbindung geschlossen.');
this.channel = null;
this.amqpAvailable = false;
});
connection.createChannel((channelError, channel) => {
if (channelError) {
console.warn('[chatService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
return;
}
this.channel = channel; this.channel = channel;
this.amqpAvailable = true;
channel.assertQueue(QUEUE, { durable: false }); channel.assertQueue(QUEUE, { durable: false });
}); });
}); });
@@ -118,7 +144,7 @@ class ChatService {
history: [messageBundle], history: [messageBundle],
}); });
} }
if (this.channel) { if (this.channel && this.amqpAvailable) {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle))); this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
} }
} }

View File

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

View File

@@ -2,38 +2,59 @@
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import amqp from 'amqplib/callback_api.js'; import amqp from 'amqplib/callback_api.js';
const RABBITMQ_URL = 'amqp://localhost'; const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'chat_messages'; const QUEUE = 'chat_messages';
export function setupWebSocket(server) { export function setupWebSocket(server) {
const io = new Server(server); const io = new Server(server);
let channel = null;
amqp.connect(RABBITMQ_URL, (err, connection) => { amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) throw err; if (err) {
console.warn(`[webSocketService] RabbitMQ nicht erreichbar (${RABBITMQ_URL}) - WebSocket läuft ohne Queue-Bridge.`);
return;
}
connection.createChannel((err, channel) => { connection.on('error', (connectionError) => {
if (err) throw err; console.warn('[webSocketService] RabbitMQ-Verbindung fehlerhaft:', connectionError.message);
channel = null;
});
connection.on('close', () => {
console.warn('[webSocketService] RabbitMQ-Verbindung geschlossen.');
channel = null;
});
connection.createChannel((channelError, createdChannel) => {
if (channelError) {
console.warn('[webSocketService] RabbitMQ-Channel konnte nicht erstellt werden:', channelError.message);
return;
}
channel = createdChannel;
channel.assertQueue(QUEUE, { durable: false }); channel.assertQueue(QUEUE, { durable: false });
channel.consume(QUEUE, (msg) => {
if (!msg) return;
const message = JSON.parse(msg.content.toString());
io.emit('newMessage', message);
}, { noAck: true });
});
});
io.on('connection', (socket) => { io.on('connection', (socket) => {
console.log('Client connected via WebSocket'); console.log('Client connected via WebSocket');
// Konsumiert Nachrichten aus RabbitMQ und sendet sie an den WebSocket-Client socket.on('newMessage', (message) => {
channel.consume(QUEUE, (msg) => { if (channel) {
const message = JSON.parse(msg.content.toString()); channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
io.emit('newMessage', message); // Broadcast an alle Clients return;
}, { noAck: true }); }
// Empfangt eine Nachricht vom WebSocket-Client und sendet sie an die RabbitMQ-Warteschlange io.emit('newMessage', message);
socket.on('newMessage', (message) => { });
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
});
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log('Client disconnected'); console.log('Client disconnected');
});
});
}); });
}); });
} }

View File

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

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_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 VITE_CHAT_WS_URL=ws://127.0.0.1:1235

View File

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

View File

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

View File

@@ -8,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="description" content="YourPart vereint Community, Chat, Forum, soziales Netzwerk mit Bildergalerie, Vokabeltrainer, das Aufbauspiel Falukant sowie Minispiele wie Match3 und Taxi. Die Plattform befindet sich in der BetaPhase und wird laufend erweitert." />
<meta name="keywords" content="YourPart, Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer, Sprachen lernen, Falukant, Aufbauspiel, Minispiele, Match3, Taxi, Bildergalerie, Tagebuch, Freundschaften" /> <meta name="keywords" content="YourPart, Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer, Sprachen lernen, Falukant, Aufbauspiel, Minispiele, Match3, Taxi, Bildergalerie, Tagebuch, Freundschaften" />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<link rel="canonical" href="https://www.your-part.de/" /> <link rel="canonical" href="%VITE_PUBLIC_BASE_URL%/" />
<meta name="author" content="YourPart" /> <meta name="author" content="YourPart" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="YourPart" /> <meta property="og:site_name" content="YourPart" />
<meta property="og:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" /> <meta property="og:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
<meta property="og:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." /> <meta property="og:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." />
<meta property="og:url" content="https://www.your-part.de/" /> <meta property="og:url" content="%VITE_PUBLIC_BASE_URL%/" />
<meta property="og:locale" content="de_DE" /> <meta property="og:locale" content="de_DE" />
<meta property="og:image" content="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:card" content="summary_large_image" />
<meta name="twitter:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" /> <meta name="twitter:title" content="YourPart Community, Chat, Forum, Vokabeltrainer, Falukant & Minispiele" />
<meta name="twitter:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." /> <meta name="twitter:description" content="Community, Chat, Forum, soziales Netzwerk, Vokabeltrainer zum Sprachen lernen, das Aufbauspiel Falukant sowie Minispiele jetzt in der Beta testen." />
<meta name="twitter:image" content="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" /> <meta name="theme-color" content="#FF8C5A" />
<link rel="alternate" hreflang="de" href="https://www.your-part.de/" /> <link rel="alternate" hreflang="de" href="%VITE_PUBLIC_BASE_URL%/" />
<link rel="alternate" hreflang="x-default" href="https://www.your-part.de/" /> <link rel="alternate" hreflang="x-default" href="%VITE_PUBLIC_BASE_URL%/" />
</head> </head>

View File

@@ -1,8 +1,8 @@
<template> <template>
<div id="app"> <div id="app" class="app-shell">
<AppHeader /> <AppHeader class="app-shell__header" />
<AppNavigation v-if="isLoggedIn && user.active" /> <AppNavigation v-if="isLoggedIn && user.active" class="app-shell__nav" />
<AppContent /> <AppContent class="app-shell__content" />
<AppFooter /> <AppFooter />
<AnswerContact ref="answerContactDialog" /> <AnswerContact ref="answerContactDialog" />
<RandomChatDialog ref="randomChatDialog" /> <RandomChatDialog ref="randomChatDialog" />
@@ -71,10 +71,10 @@ export default {
</script> </script>
<style> <style>
#app { .app-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100vh;
overflow: hidden; overflow: hidden;
} }
</style> </style>

View File

@@ -1,50 +1,282 @@
:root { :root {
/* Moderne Farbpalette für bessere Lesbarkeit */ color-scheme: light;
--color-primary-orange: #FFB84D; /* Gelbliches, sanftes Orange */
--color-primary-orange-hover: #FFC966; /* Noch helleres gelbliches Orange für Hover */ --font-display: "Trebuchet MS", "Segoe UI", sans-serif;
--color-primary-orange-light: #FFF8E1; /* Sehr helles gelbliches Orange für Hover-Hintergründe */ --font-body: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--color-primary-green: #4ADE80; /* Helles, freundliches Grün - passt zum Orange */
--color-primary-green-hover: #6EE7B7; /* Noch helleres Grün für Hover */ --color-bg: #f4f1ea;
--color-text-primary: #1F2937; /* Dunkles Grau für Haupttext (bessere Lesbarkeit) */ --color-bg-elevated: #faf7f1;
--color-text-secondary: #5D4037; /* Dunkles Braun für sekundären Text/Hover */ --color-bg-muted: #f5eee2;
--color-text-on-orange: #000000; /* Schwarz auf Orange */ --color-surface: rgba(255, 251, 246, 0.94);
--color-text-on-green: #000000; /* Schwarz auf Grün */ --color-surface-strong: #fffdfa;
--color-surface-accent: #fff4e5;
--color-border: rgba(93, 64, 55, 0.12);
--color-border-strong: rgba(93, 64, 55, 0.24);
--color-text-primary: #211910;
--color-text-secondary: #5f4b39;
--color-text-muted: #7a6857;
--color-text-on-accent: #fffaf4;
--color-primary: #f8a22b;
--color-primary-hover: #ea961f;
--color-primary-soft: rgba(248, 162, 43, 0.14);
--color-secondary: #78c38a;
--color-secondary-soft: rgba(120, 195, 138, 0.18);
--color-highlight: #ffcf74;
--color-success: #287d5a;
--color-warning: #c9821f;
--color-danger: #b13b35;
--shell-max-width: 1440px;
--content-max-width: 1200px;
--header-height: 62px;
--nav-height: 52px;
--footer-height: 46px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--radius-sm: 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, html,
body { body,
#app {
height: 100%; height: 100%;
} }
html {
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.85), transparent 30%),
linear-gradient(180deg, #f8f2e8 0%, #f3ebdd 100%);
}
body { body {
font-family: Arial, sans-serif;
margin: 0; margin: 0;
padding: 0; padding: 0;
background-color: #f4f4f4; font-family: var(--font-body);
color: var(--color-text-primary); color: var(--color-text-primary);
background: transparent;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow: hidden;
} }
a { a {
text-decoration: none;
color: inherit; color: inherit;
text-decoration: none;
transition: color var(--transition-fast);
} }
button { a:hover {
margin-left: 10px; color: var(--color-primary);
padding: 5px 12px; }
cursor: pointer;
background: var(--color-primary-orange); button,
color: var(--color-text-on-orange); input,
border: 1px solid var(--color-primary-orange); select,
border-radius: 4px; textarea {
transition: background 0.05s; font: inherit;
}
button,
.button,
span.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 42px;
padding: 0 18px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-pill);
background: var(--color-primary);
color: #2b1f14;
box-shadow: 0 6px 14px rgba(248, 162, 43, 0.2);
cursor: pointer;
transition:
transform var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast),
border-color var(--transition-fast);
} }
button:hover { button:hover,
background: var(--color-primary-orange-light); .button:hover,
color: var(--color-text-secondary); span.button:hover {
border: 1px solid var(--color-text-secondary); 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 { .rc-system {
@@ -52,25 +284,13 @@ button:hover {
} }
.rc-self { .rc-self {
color: #ff0000; color: #c0412c;
font-weight: bold; font-weight: 700;
} }
.rc-partner { .rc-partner {
color: #0000ff; color: #2357b5;
font-weight: bold; font-weight: 700;
}
.link {
color: var(--color-primary-orange);
cursor: pointer;
}
h1,
h2,
h3 {
margin: 0;
display: block;
} }
.multiselect__option--highlight, .multiselect__option--highlight,
@@ -80,61 +300,42 @@ h3 {
.multiselect__option--highlight[data-selected], .multiselect__option--highlight[data-selected],
.multiselect__option--highlight[data-deselect] { .multiselect__option--highlight[data-deselect] {
background: none; background: none;
background-color: var(--color-primary-orange); background-color: var(--color-primary);
color: var(--color-text-on-orange); color: var(--color-text-on-accent);
}
span.button {
padding: 2px 2px;
margin-left: 4px;
cursor: pointer;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
border: 1px solid var(--color-primary-orange);
border-radius: 4px;
transition: background 0.05s;
border: 1px solid transparent;
width: 1.2em;
height: 1.2em;
display: inline-block;
text-align: center;
line-height: 1.2em;
}
span.button:hover {
background: var(--color-primary-orange-light);
color: var(--color-text-secondary);
border: 1px solid var(--color-text-secondary);
} }
.font-color-gender-male { .font-color-gender-male {
color: #1E90FF; color: #1e90ff;
} }
.font-color-gender-female { .font-color-gender-female {
color: #FF69B4; color: #d14682;
} }
.font-color-gender-transmale { .font-color-gender-transmale {
color: #00CED1; color: #1f8b9b;
} }
.font-color-gender-transfemale { .font-color-gender-transfemale {
color: #FFB6C1; color: #d78398;
} }
.font-color-gender-nonbinary { .font-color-gender-nonbinary {
color: #DAA520; color: #ba7c1f;
} }
main, @media (max-width: 960px) {
.contenthidden { :root {
width: 100%; --header-height: 56px;
height: 100%; --nav-height: auto;
overflow: hidden; --footer-height: auto;
}
h1 {
font-size: clamp(1.8rem, 8vw, 2.8rem);
}
h2 {
font-size: clamp(1.35rem, 5vw, 2rem);
}
} }
.contentscroll {
width: 100%;
height: 100%;
overflow: auto;
}

View File

@@ -1,10 +1,12 @@
<template> <template>
<main class="contenthidden"> <main class="app-content contenthidden">
<div class="contentscroll"> <div class="app-content__scroll contentscroll">
<div class="app-content__inner">
<router-view></router-view> <router-view></router-view>
</div> </div>
</main> </div>
</template> </main>
</template>
<script> <script>
export default { export default {
@@ -12,15 +14,31 @@
}; };
</script> </script>
<style scoped> <style scoped>
main { .app-content {
padding: 0; flex: 1;
background-color: #ffffff; min-height: 0;
flex: 1; padding: 0;
overflow: hidden;
}
.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>
.contentscroll {
padding: 20px;
}
</style>

View File

@@ -1,18 +1,23 @@
<template> <template>
<footer> <footer class="app-footer">
<div class="logo" @click="showFalukantDaemonStatus"><img src="/images/icons/logo_color.png"></div> <div class="app-footer__inner">
<div class="window-bar"> <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" <button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle"> @click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle">
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" /> <img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) : <span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span> dialog.dialog.localTitle }}</span>
</button> </button>
</div> </div>
<div class="static-block"> <div class="static-block">
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a> <a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a> <a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
<a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a> <a href="#" @click.prevent="openContactDialog">{{ $t('contact.button') }}</a>
</div>
</div> </div>
</footer> </footer>
</template> </template>
@@ -63,18 +68,47 @@ export default {
</script> </script>
<style scoped> <style scoped>
footer { .app-footer {
display: flex; flex: 0 0 auto;
background-color: var(--color-primary-green); padding: 0;
height: 38px;
width: 100%;
color: #1F2937; /* Dunkles Grau für besseren Kontrast auf hellem Grün */
} }
.logo, .app-footer__inner {
.window-bar, max-width: var(--shell-max-width);
.static-block { margin: 0 auto;
text-align: center; display: flex;
align-items: center;
gap: 16px;
min-height: 44px;
padding: 6px 12px;
border-radius: 0;
background:
linear-gradient(180deg, rgba(242, 248, 243, 0.96) 0%, rgba(224, 238, 227, 0.98) 100%);
border-top: 1px solid rgba(120, 195, 138, 0.28);
box-shadow: 0 -6px 18px rgba(93, 64, 55, 0.06);
}
.footer-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 { .window-bar {
@@ -83,24 +117,25 @@ footer {
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 10px; gap: 10px;
padding-left: 10px; overflow: auto;
} }
.dialog-button { .dialog-button {
max-width: 12em; max-width: 15em;
min-height: 30px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 5px 10px; padding: 0 12px;
border: none; border-radius: 999px;
border-radius: 4px; background: rgba(255, 255, 255, 0.7);
cursor: pointer; color: var(--color-text-primary);
display: flex; border: 1px solid rgba(120, 195, 138, 0.18);
align-items: center; box-shadow: none;
background: none; }
height: 1.8em;
border: 1px solid #0a4337; .dialog-button:hover {
box-shadow: 1px 1px 2px #484949; background: rgba(255, 255, 255, 0.92);
} }
.dialog-button>img { .dialog-button>img {
@@ -111,16 +146,35 @@ footer {
margin-left: 5px; margin-left: 5px;
} }
.logo>img {
width: 36px;
height: 36px;
}
.static-block { .static-block {
line-height: 38px; display: flex;
align-items: center;
gap: 18px;
white-space: nowrap;
} }
.static-block>a { .static-block>a {
padding-right: 1.5em; color: #42634e;
font-weight: 600;
} }
</style>
.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> <template>
<header> <header class="app-header">
<div class="logo"><img src="/images/logos/logo.png" /></div> <div class="app-header__inner">
<div class="advertisement">Advertisement</div> <div class="brand">
<div class="connection-status" v-if="isLoggedIn"> <div class="logo"><img src="/images/logos/logo.png" alt="YourPart" /></div>
<div class="status-indicator" :class="backendStatusClass"> <div class="brand-copy">
<span class="status-dot"></span> <strong>YourPart</strong>
<span class="status-text">B</span> <span>Community, Spiele und Lernen auf einer Plattform</span>
</div>
</div> </div>
<div class="status-indicator" :class="daemonStatusClass"> <div class="header-meta">
<span class="status-dot"></span> <div class="header-pill">Beta</div>
<span class="status-text">D</span> <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>
</div> </div>
</header> </header>
@@ -43,43 +53,107 @@ export default {
</script> </script>
<style scoped> <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; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 10px; gap: 16px;
background-color: #f8a22b;
} }
.logo, .title, .advertisement {
text-align: center; .brand {
display: flex;
align-items: center;
gap: 12px;
} }
.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 { .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 { .connection-status {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: 10px; gap: 8px;
gap: 5px;
} }
.status-indicator { .status-indicator {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 2px 6px; gap: 8px;
border-radius: 4px; min-height: 28px;
font-size: 6pt; padding: 0 10px;
font-weight: 500; border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
border: 1px solid rgba(93, 64, 55, 0.1);
background: rgba(255, 255, 255, 0.62);
} }
.status-dot { .status-dot {
width: 6px; width: 8px;
height: 6px; height: 8px;
border-radius: 50%; border-radius: 50%;
margin-right: 4px;
animation: pulse 2s infinite; animation: pulse 2s infinite;
} }
@@ -100,23 +174,23 @@ header {
} }
.status-connected { .status-connected {
background-color: rgba(76, 175, 80, 0.1); background-color: rgba(76, 175, 80, 0.12);
color: #2e7d32; color: #245b2c;
} }
.status-connecting { .status-connecting {
background-color: rgba(255, 152, 0, 0.1); background-color: rgba(255, 152, 0, 0.12);
color: #f57c00; color: #8b5e0d;
} }
.status-disconnected { .status-disconnected {
background-color: rgba(244, 67, 54, 0.1); background-color: rgba(244, 67, 54, 0.12);
color: #d32f2f; color: #8f2c27;
} }
.status-error { .status-error {
background-color: rgba(244, 67, 54, 0.1); background-color: rgba(244, 67, 54, 0.12);
color: #d32f2f; color: #8f2c27;
} }
@keyframes pulse { @keyframes pulse {
@@ -124,4 +198,24 @@ header {
50% { opacity: 0.5; } 50% { opacity: 0.5; }
100% { opacity: 1; } 100% { opacity: 1; }
} }
@media (max-width: 960px) {
.app-header {
padding: 6px 10px;
}
.app-header__inner {
gap: 8px;
flex-wrap: wrap;
}
.header-meta {
justify-content: space-between;
flex-wrap: wrap;
}
.brand-copy span {
font-size: 0.76rem;
}
}
</style> </style>

View File

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

View File

@@ -1,5 +1,13 @@
<template> <template>
<div ref="container" class="character-3d-container"></div> <div class="character-3d-shell">
<div v-show="!showFallback" ref="container" class="character-3d-container"></div>
<img
v-if="showFallback"
class="character-fallback"
:src="fallbackImageSrc"
:alt="`Character ${actualGender}`"
/>
</div>
</template> </template>
<script> <script>
@@ -41,7 +49,8 @@ export default {
animationId: null, animationId: null,
mixer: null, mixer: null,
clock: markRaw(new THREE.Clock()), clock: markRaw(new THREE.Clock()),
baseYPosition: 0 // Basis-Y-Position für Animation baseYPosition: 0,
showFallback: false
}; };
}, },
computed: { computed: {
@@ -93,6 +102,11 @@ export default {
const base = getApiBaseURL(); const base = getApiBaseURL();
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH; const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
return `${prefix}/${this.actualGender}_${age}y.glb`; return `${prefix}/${this.actualGender}_${age}y.glb`;
},
fallbackImageSrc() {
return this.actualGender === 'female'
? '/images/mascot/mascot_female.png'
: '/images/mascot/mascot_male.png';
} }
}, },
watch: { watch: {
@@ -115,6 +129,7 @@ export default {
init3D() { init3D() {
const container = this.$refs.container; const container = this.$refs.container;
if (!container) return; if (!container) return;
this.showFallback = false;
// Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden // Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
this.scene = markRaw(new THREE.Scene()); this.scene = markRaw(new THREE.Scene());
@@ -301,6 +316,7 @@ export default {
} }
} catch (error) { } catch (error) {
console.error('Error loading 3D model:', error); console.error('Error loading 3D model:', error);
this.showFallback = true;
} }
}, },
@@ -375,10 +391,25 @@ export default {
</script> </script>
<style scoped> <style scoped>
.character-3d-shell {
width: 100%;
height: 100%;
min-height: 0;
position: relative;
}
.character-3d-container { .character-3d-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.character-fallback {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center bottom;
}
</style> </style>

View File

@@ -23,11 +23,19 @@
<img :src="'/images/icons/falukant/relationship-' + item.image + '.png'" class="relationship-icon" :title="$t(`falukant.statusbar.${item.key}`)" /> <img :src="'/images/icons/falukant/relationship-' + item.image + '.png'" class="relationship-icon" :title="$t(`falukant.statusbar.${item.key}`)" />
</div> </div>
</template> </template>
<span v-if="statusItems.length > 0 && menu.falukant && menu.falukant.children"> <div
v-if="statusItems.length > 0 && menu.falukant && menu.falukant.children"
class="quick-access"
>
<template v-for="(menuItem, key) in menu.falukant.children" :key="menuItem.id" > <template v-for="(menuItem, key) in menu.falukant.children" :key="menuItem.id" >
<img :src="'/images/icons/falukant/shortmap/' + key + '.png'" class="menu-icon" @click="openPage(menuItem)" :title="$t(`navigation.m-falukant.${key}`)" /> <img
:src="'/images/icons/falukant/shortmap/' + key + '.png'"
class="menu-icon"
@click="openPage(menuItem)"
:title="$t(`navigation.m-falukant.${key}`)"
/>
</template> </template>
</span> </div>
<MessagesDialog ref="msgs" /> <MessagesDialog ref="msgs" />
</div> </div>
</template> </template>
@@ -220,13 +228,18 @@ export default {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-wrap: wrap;
background-color: #f4f4f4; background-color: #f4f4f4;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
width: calc(100% + 40px); box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 1.2em; gap: 1.2em;
margin: -21px -20px 1.5em -20px; padding: 0.4rem 0.75rem;
position: fixed; margin: 0 0 1.5em 0;
position: sticky;
top: 0;
z-index: 100; z-index: 100;
} }
@@ -237,6 +250,14 @@ export default {
align-items: center; align-items: center;
} }
.quick-access {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.2rem;
}
.status-icon-wrapper { .status-icon-wrapper {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -254,6 +275,8 @@ export default {
.menu-icon { .menu-icon {
width: 30px; width: 30px;
height: 30px; height: 30px;
display: block;
flex: 0 0 auto;
cursor: pointer; cursor: pointer;
padding: 4px 2px 0 0; padding: 4px 2px 0 0;
} }

View File

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

View File

@@ -10,7 +10,7 @@ import blogRoutes from './blogRoutes';
import minigamesRoutes from './minigamesRoutes'; import minigamesRoutes from './minigamesRoutes';
import personalRoutes from './personalRoutes'; import personalRoutes from './personalRoutes';
import marketingRoutes from './marketingRoutes'; import marketingRoutes from './marketingRoutes';
import { applyRouteSeo } from '../utils/seo'; import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
const routes = [ const routes = [
{ {
@@ -28,12 +28,12 @@ const routes = [
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebSite', '@type': 'WebSite',
name: 'YourPart', name: 'YourPart',
url: 'https://www.your-part.de/', url: buildAbsoluteUrl('/'),
inLanguage: 'de', inLanguage: 'de',
description: 'Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen.', description: 'Community-Plattform mit Chat, Forum, Blogs, Vokabeltrainer, Falukant und Browser-Minispielen.',
potentialAction: { potentialAction: {
'@type': 'SearchAction', '@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', '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 FalukantLandingView = () => import('../views/public/FalukantLandingView.vue');
const MinigamesLandingView = () => import('../views/public/MinigamesLandingView.vue'); const MinigamesLandingView = () => import('../views/public/MinigamesLandingView.vue');
const VocabLandingView = () => import('../views/public/VocabLandingView.vue'); const VocabLandingView = () => import('../views/public/VocabLandingView.vue');
@@ -18,7 +20,7 @@ const marketingRoutes = [
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'VideoGame', '@type': 'VideoGame',
name: 'Falukant', name: 'Falukant',
url: 'https://www.your-part.de/falukant', url: buildAbsoluteUrl('/falukant'),
description: 'Mittelalterliches Browser-Aufbauspiel mit Handel, Politik, Familie und Charakterentwicklung.', description: 'Mittelalterliches Browser-Aufbauspiel mit Handel, Politik, Familie und Charakterentwicklung.',
gamePlatform: 'Web Browser', gamePlatform: 'Web Browser',
applicationCategory: 'Game', applicationCategory: 'Game',
@@ -47,7 +49,7 @@ const marketingRoutes = [
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'CollectionPage', '@type': 'CollectionPage',
name: 'YourPart Minispiele', name: 'YourPart Minispiele',
url: 'https://www.your-part.de/minigames', url: buildAbsoluteUrl('/minigames'),
description: 'Browser-Minispiele auf YourPart mit Match 3 und Taxi.', description: 'Browser-Minispiele auf YourPart mit Match 3 und Taxi.',
inLanguage: 'de', inLanguage: 'de',
}, },
@@ -70,7 +72,7 @@ const marketingRoutes = [
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'SoftwareApplication', '@type': 'SoftwareApplication',
name: 'YourPart Vokabeltrainer', name: 'YourPart Vokabeltrainer',
url: 'https://www.your-part.de/vokabeltrainer', url: buildAbsoluteUrl('/vokabeltrainer'),
description: 'Interaktiver Vokabeltrainer mit Kursen, Lektionen und Übungen zum Sprachenlernen.', description: 'Interaktiver Vokabeltrainer mit Kursen, Lektionen und Übungen zum Sprachenlernen.',
applicationCategory: 'EducationalApplication', applicationCategory: 'EducationalApplication',
operatingSystem: 'Web', 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 // Small helper to resolve the Chat WebSocket URL from env or sensible defaults
export function getChatWsUrl() { export function getChatWsUrl() {
// Prefer explicit env var // Prefer explicit env var
@@ -5,24 +7,7 @@ export function getChatWsUrl() {
if (override && typeof override === 'string' && override.trim()) { if (override && typeof override === 'string' && override.trim()) {
return override.trim(); return override.trim();
} }
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL; return getChatWsUrlFromEnv();
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) {
return envUrl.trim();
}
// Fallback: use current origin host with ws/wss and default port/path if provided by backend
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
const proto = isHttps ? 'wss' : 'ws';
// If a reverse proxy exposes the chat at a path, you can change '/chat' here.
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
// On localhost, prefer dedicated chat port 1235 by default
// Prefer IPv4 for localhost to avoid browsers resolving to ::1 (IPv6) where the server may not listen
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
return `${proto}://127.0.0.1:1235`;
}
// Default to same origin with chat port for production
const defaultUrl = `${proto}://${host}:1235`;
return defaultUrl;
} }
// Provide a list of candidate WS URLs to try, in order of likelihood. // Provide a list of candidate WS URLs to try, in order of likelihood.
@@ -31,37 +16,8 @@ export function getChatWsCandidates() {
if (override && typeof override === 'string' && override.trim()) { if (override && typeof override === 'string' && override.trim()) {
return [override.trim()]; return [override.trim()];
} }
const envUrl = import.meta?.env?.VITE_CHAT_WS_URL; const resolved = getChatWsUrlFromEnv();
if (envUrl && typeof envUrl === 'string' && envUrl.trim()) { return [resolved, `${resolved}/`];
return [envUrl.trim()];
}
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
const proto = isHttps ? 'wss' : 'ws';
const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
const port = (typeof window !== 'undefined' && window.location.port) ? `:${window.location.port}` : '';
const candidates = [];
// Common local setups: include IPv4 and IPv6 loopback variants (root only)
if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') {
// Prefer IPv6 loopback first when available
const localHosts = ['[::1]', '127.0.0.1', 'localhost'];
for (const h of localHosts) {
const base = `${proto}://${h}:1235`;
candidates.push(base);
candidates.push(`${base}/`);
}
}
// Same-origin with chat port
const sameOriginBases = [`${proto}://${host}:1235`];
// If localhost-ish, also try 127.0.0.1 for chat port
if (host === 'localhost' || host === '::1' || host === '[::1]') {
sameOriginBases.push(`${proto}://[::1]:1235`);
sameOriginBases.push(`${proto}://127.0.0.1:1235`);
}
for (const base of sameOriginBases) {
candidates.push(base);
candidates.push(`${base}/`);
}
return candidates;
} }
// Return optional subprotocols for the WebSocket handshake. // Return optional subprotocols for the WebSocket handshake.
@@ -84,4 +40,4 @@ export function getChatWsProtocols() {
// Default to the 'chat' subprotocol so the server can gate connections accordingly // Default to the 'chat' subprotocol so the server can gate connections accordingly
return ['chat']; return ['chat'];
} }

View File

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

View File

@@ -0,0 +1,61 @@
function trimTrailingSlash(value) {
return value ? value.replace(/\/$/, '') : value;
}
function getWindowOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function toWsOrigin(value) {
if (!value) {
return value;
}
return value
.replace(/^http:\/\//i, 'ws://')
.replace(/^https:\/\//i, 'wss://');
}
export function getApiBaseUrl() {
return trimTrailingSlash(import.meta.env.VITE_API_BASE_URL || getWindowOrigin() || '');
}
export function getSocketIoUrl() {
return trimTrailingSlash(import.meta.env.VITE_SOCKET_IO_URL || getApiBaseUrl() || getWindowOrigin() || '');
}
export function getDaemonSocketUrl() {
const configured = import.meta.env.VITE_DAEMON_SOCKET;
if (configured) {
return configured;
}
return toWsOrigin(getWindowOrigin());
}
export function getPublicBaseUrl() {
return trimTrailingSlash(import.meta.env.VITE_PUBLIC_BASE_URL || getWindowOrigin() || 'https://www.your-part.de');
}
export function getChatWsUrlFromEnv() {
const directUrl = import.meta.env.VITE_CHAT_WS_URL;
if (directUrl) {
return directUrl.trim();
}
const host = import.meta.env.VITE_CHAT_WS_HOST;
const port = import.meta.env.VITE_CHAT_WS_PORT;
const protocol = import.meta.env.VITE_CHAT_WS_PROTOCOL || (typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss' : 'ws');
if (host || port) {
const resolvedHost = host || (typeof window !== 'undefined' ? window.location.hostname : 'localhost');
const resolvedPort = port ? `:${port}` : '';
return `${protocol}://${resolvedHost}${resolvedPort}`;
}
return toWsOrigin(getWindowOrigin());
}

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import { getPublicBaseUrl } from './appConfig.js';
const DEFAULT_BASE_URL = 'https://www.your-part.de'; const DEFAULT_BASE_URL = 'https://www.your-part.de';
const DEFAULT_SITE_NAME = 'YourPart'; const DEFAULT_SITE_NAME = 'YourPart';
const DEFAULT_TITLE = 'YourPart - Community, Chat, Forum, Vokabeltrainer, Falukant und Minispiele'; const DEFAULT_TITLE = 'YourPart - Community, Chat, Forum, Vokabeltrainer, Falukant und Minispiele';
@@ -21,7 +23,7 @@ const MANAGED_META_KEYS = [
]; ];
function getBaseUrl() { 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) { function upsertMeta(attr, key, content) {

View File

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