Refactor backend CORS settings to include default origins and improve error handling in chat services: Introduce dynamic CORS origin handling, enhance RabbitMQ message sending with fallback mechanisms, and update WebSocket service to manage pending messages. Update UI components for better accessibility and responsiveness, including adjustments to dialog and navigation elements. Enhance styling for improved user experience across various components.

This commit is contained in:
Torsten Schulz (local)
2026-03-19 14:44:04 +01:00
parent 4442937ebd
commit 9d44a265ca
67 changed files with 5426 additions and 1099 deletions

View File

@@ -36,10 +36,18 @@ const app = express();
// - LOG_ALL_REQ=1: Logge alle Requests
const LOG_ALL_REQ = process.env.LOG_ALL_REQ === '1';
const LOG_SLOW_REQ_MS = Number.parseInt(process.env.LOG_SLOW_REQ_MS || '500', 10);
const defaultCorsOrigins = [
'http://localhost:3000',
'http://localhost:5173',
'http://127.0.0.1:3000',
'http://127.0.0.1:5173'
];
const corsOrigins = (process.env.CORS_ORIGINS || process.env.FRONTEND_URL || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
const effectiveCorsOrigins = corsOrigins.length > 0 ? corsOrigins : defaultCorsOrigins;
const corsAllowAll = process.env.CORS_ALLOW_ALL === '1';
app.use((req, res, next) => {
const reqId = req.headers['x-request-id'] || (crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(8).toString('hex'));
@@ -61,7 +69,7 @@ const corsOptions = {
return callback(null, true);
}
if (corsOrigins.length === 0 || corsOrigins.includes(origin)) {
if (corsAllowAll || effectiveCorsOrigins.includes(origin)) {
return callback(null, true);
}

View File

@@ -145,7 +145,13 @@ class ChatService {
});
}
if (this.channel && this.amqpAvailable) {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
try {
this.channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(messageBundle)));
} catch (error) {
console.warn('[chatService] sendToQueue fehlgeschlagen, Queue-Bridge vorübergehend deaktiviert:', error.message);
this.channel = null;
this.amqpAvailable = false;
}
}
}

View File

@@ -389,6 +389,8 @@ class FalukantService extends BaseService {
one: { min: 50, max: 5000 },
all: { min: 400, max: 40000 }
};
static WOOING_PROGRESS_TARGET = 70;
static WOOING_GIFT_COOLDOWN_MS = 30 * 60 * 1000;
static HEALTH_ACTIVITIES = [
{ tr: "barber", method: "healthBarber", cost: 10 },
{ tr: "doctor", method: "healthDoctor", cost: 50 },
@@ -3311,8 +3313,8 @@ class FalukantService extends BaseService {
order: [['createdAt', 'DESC']],
limit: 1
});
if (lastGift && (lastGift.createdAt.getTime() + 3_600_000) > Date.now()) {
const retryAt = new Date(lastGift.createdAt.getTime() + 3_600_000);
if (lastGift && (lastGift.createdAt.getTime() + FalukantService.WOOING_GIFT_COOLDOWN_MS) > Date.now()) {
const retryAt = new Date(lastGift.createdAt.getTime() + FalukantService.WOOING_GIFT_COOLDOWN_MS);
const err = new PreconditionError('tooOften');
err.meta = { retryAt: retryAt.toISOString() };
throw err;
@@ -3381,7 +3383,7 @@ class FalukantService extends BaseService {
async checkProposalProgress(relation) {
const { nextStepProgress } = relation;
if (nextStepProgress >= 100) {
if (nextStepProgress >= FalukantService.WOOING_PROGRESS_TARGET) {
const engagedStatus = await RelationshipType.findOne({ where: { tr: 'engaged' } });
await relation.update({ nextStepProgress: 0, relationshipTypeId: engagedStatus.id });
const user = await User.findOne({

View File

@@ -4,10 +4,50 @@ import amqp from 'amqplib/callback_api.js';
const RABBITMQ_URL = process.env.AMQP_URL || 'amqp://localhost';
const QUEUE = 'chat_messages';
const MAX_PENDING_MESSAGES = 500;
function routeMessage(io, message) {
if (!message || typeof message !== 'object') return;
if (message.socketId) {
io.to(message.socketId).emit('newMessage', message);
return;
}
if (message.recipientSocketId) {
io.to(message.recipientSocketId).emit('newMessage', message);
return;
}
if (message.roomId) {
io.to(String(message.roomId)).emit('newMessage', message);
return;
}
if (message.room) {
io.to(String(message.room)).emit('newMessage', message);
return;
}
io.emit('newMessage', message);
}
export function setupWebSocket(server) {
const io = new Server(server);
let channel = null;
let pendingMessages = [];
const flushPendingMessages = () => {
if (!channel || pendingMessages.length === 0) return;
const queued = pendingMessages;
pendingMessages = [];
for (const message of queued) {
try {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
} catch (err) {
console.warn('[webSocketService] Flush fehlgeschlagen, Nachricht bleibt im Fallback:', err.message);
pendingMessages.unshift(message);
break;
}
}
};
amqp.connect(RABBITMQ_URL, (err, connection) => {
if (err) {
@@ -36,8 +76,9 @@ export function setupWebSocket(server) {
channel.consume(QUEUE, (msg) => {
if (!msg) return;
const message = JSON.parse(msg.content.toString());
io.emit('newMessage', message);
routeMessage(io, message);
}, { noAck: true });
flushPendingMessages();
});
});
@@ -46,11 +87,21 @@ export function setupWebSocket(server) {
socket.on('newMessage', (message) => {
if (channel) {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
return;
try {
channel.sendToQueue(QUEUE, Buffer.from(JSON.stringify(message)));
} catch (err) {
console.warn('[webSocketService] sendToQueue fehlgeschlagen, nutze In-Memory-Fallback:', err.message);
channel = null;
}
}
io.emit('newMessage', message);
if (!channel) {
pendingMessages.push(message);
if (pendingMessages.length > MAX_PENDING_MESSAGES) {
pendingMessages = pendingMessages.slice(-MAX_PENDING_MESSAGES);
}
return;
}
});
socket.on('disconnect', () => {

View File

@@ -1,5 +1,16 @@
# UI Redesign Plan
## Status
- Prioritaet 1 ist umgesetzt.
- Prioritaet 2 ist umgesetzt.
- Phase 3 ist abgeschlossen.
- Phase 4 ist abgeschlossen.
- Phase 5 ist abgeschlossen.
- Das Redesign ist damit insgesamt abgeschlossen.
- Optionaler technischer Nachlauf:
- weiterer Performance-Feinschliff rund um den separaten three-Chunk
## Ziel
Das Frontend von YourPart soll visuell und strukturell modernisiert werden, ohne die bestehende Funktionsbreite zu verlieren. Der Fokus liegt auf einem klareren Designsystem, besserer Informationshierarchie, konsistenter Navigation, responsiver Nutzung und einer deutlich hochwertigeren Wahrnehmung auf Startseite, Community-Bereichen und Spiele-/Lernseiten.
@@ -138,11 +149,23 @@ Aktueller Stand aus dem Code:
- Danach Vokabeltrainer-Landing/Kernseiten.
- Danach Falukant- und Minigame-Einstiege.
Aktueller Stand:
- abgeschlossen
- umgesetzt fuer Home, Blogs, zentrale Social-/Community-Flaechen, Vokabeltrainer-Kernseiten, Kalender und zentrale Falukant-Einstiege
- Restpunkte in tieferen Vokabel-, Minigame-, Settings- und Admin-Ansichten sind nachgezogen
### Phase 4: Tiefere Produktbereiche
- Sekundaere Ansichten und Admin-Bereiche nachziehen.
- Visuelle Altlasten in Randbereichen bereinigen.
Aktueller Stand:
- abgeschlossen
- zentrale Produktbereiche, tiefere Community-/Vokabel-/Kalender-Ansichten sowie zentrale Falukant-Einstiege und Dialog-Basis sind modernisiert
- verbleibende Rand- und Spezialansichten aus Settings, Admin und Minigames sind visuell angeglichen
### Phase 5: QA und Verfeinerung
- Responsive Review.
@@ -150,6 +173,13 @@ Aktueller Stand aus dem Code:
- Performance-Pruefung auf unnötige visuelle Last.
- Konsistenz-Check ueber das gesamte Produkt.
Aktueller Stand:
- abgeschlossen
- globale Bewegungsreduktion, verbesserte Fokusfuehrung und Tastaturzugang in der Hauptnavigation umgesetzt
- Build-Chunking verbessert; Haupt-Chunk bereits reduziert
- 3D-Runtime in Character3D auf Lazy-Loading umgestellt; verbleibende Warnung betrifft den separaten three-Chunk selbst
## Empfohlene technische Arbeitspakete
### Paket A: Design Tokens

389
docs/USABILITY_AUDIT_U1.md Normal file
View File

@@ -0,0 +1,389 @@
# UX Audit U1
## Ziel
Dieser Audit bildet Phase U1 des Bedienbarkeitskonzepts ab. Er dient als priorisierte Arbeitsgrundlage fuer die eigentliche UX-Ueberarbeitung.
Untersucht wurden:
- Shell und Navigation
- Einstieg ohne Login
- Registrierung/Login
- Forum
- Vokabeltrainer
- Falukant
- Admin
- Match3/Minigames
## Bewertungslogik
- `P1`: blockiert Kernnutzung oder fuehrt sehr leicht zu Fehlbedienung
- `P2`: verlangsamt oder verkompliziert Kernnutzung merklich
- `P3`: Konsistenz-, Lesbarkeits- oder Komfortproblem
- `P4`: Feinschliff
## Gepruefte Hauptaufgaben
1. Einloggen und Einstieg verstehen
2. Registrieren
3. Forum finden, Thema erstellen und lesen
4. Vokabelsprache finden, anlegen, abonnieren und lernen
5. Falukant-Status erfassen und Folgeaktion auswaehlen
6. Admin-Nutzer oder Match3-Daten bearbeiten
7. Match3 starten, pausieren und Kampagnenstatus verstehen
## Ergebnisuebersicht
- P1: 4 Punkte
- P2: 11 Punkte
- P3: 13 Punkte
- P4: 6 Punkte
## P1-Probleme
### P1-1: Historische innere Scrollkonzepte in Teilbereichen
Bereiche:
- Falukant
- Admin
- Minigames
Beobachtung:
- Mehrere Views arbeiten weiterhin mit eigenen `contenthidden/contentscroll`-Strukturen innerhalb des bereits scrollbaren App-Contents.
- Das fuehrt zu falschen Sticky-Bezuegen, abgeschnittenen Bereichen und inkonsistenter Scrolllogik.
Risiko:
- Nutzer verlieren Orientierung.
- statische Leisten oder Footer/Header verhalten sich unerwartet.
Empfehlung:
- alle View-internen Scrollcontainer systematisch inventarisieren
- entscheiden, welche Bereiche echte lokale Scrollflaechen brauchen und welche komplett auf die Shell-Scrolllogik gehen
### P1-2: Fehler- und Erfolgsfeedback ist nicht konsistent genug
Bereiche:
- Auth
- Settings
- Admin
- Vokabeltrainer
Beobachtung:
- Mischung aus `alert`, MessageDialog, ErrorDialog, DialogWidget-internem Feedback und stillen `console.error`-Pfaden.
- Beispiel: [RegisterDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/auth/RegisterDialog.vue) nutzt fehleranfaellig `errrorDialog` statt eines klar vereinheitlichten Feedbackpfads.
Risiko:
- Nutzer verstehen nicht sicher, ob eine Aktion fehlgeschlagen ist oder erfolgreich war.
- Fehler werden in einzelnen Flows inkonsistent oder gar nicht sichtbar.
Empfehlung:
- gemeinsames Feedbacksystem definieren
- `success`, `warning`, `error`, `info`, `loading` als einheitliche Muster durchziehen
### P1-3: Formvalidierung erfolgt oft zu spaet oder zu unsichtbar
Bereiche:
- Registrierung
- Account-Settings
- Admin
- Falukant-Formulare
Beobachtung:
- viele Formulare validieren erst beim Absenden
- Feldfehler sitzen selten direkt am Eingabepunkt
- Pflichtlogik ist nicht durchgaengig erkennbar
Risiko:
- hohe Reibung beim Ausfuellen
- Wiederholschleifen und Frust bei laengeren Formularen
Empfehlung:
- Validierung naeher ans Feld bringen
- Pflichtfelder, Eingabeformat und Fehltext systematisch sichtbar machen
### P1-4: Komplexe Arbeitsflaechen haben zu wenig gefuehrte Primaeraktionen
Bereiche:
- Falukant
- Admin Match3
Beobachtung:
- Fachflaechen zeigen viele Optionen gleichzeitig, ohne klare Priorisierung.
- Beispiel: [MinigamesView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/admin/MinigamesView.vue) ist funktionsreich, aber als Bearbeitungsfluss kaum gefuehrt.
Risiko:
- Nutzer verstehen den naechsten sinnvollen Schritt nicht.
- Bedienfehler in fachlich dichten Bereichen steigen.
Empfehlung:
- pro komplexer View den eigentlichen Arbeitsfluss definieren
- Primaeraktionen, Sekundaeraktionen und Expertenaktionen sichtbarer trennen
## P2-Probleme
### P2-1: Hauptnavigation ist funktional, aber noch nicht ausreichend auf Aufgaben priorisiert
Beobachtung:
- Navigation ist technisch konsistenter als vorher, aber die inhaltliche Priorisierung der Menuepunkte ist noch stark historisch gewachsen.
- Der Unterschied zwischen Kernzielen und Tiefenfunktionen ist nicht deutlich genug.
Empfehlung:
- Menueaufbau gegen echte Nutzungsszenarien pruefen
- seltene Spezialpunkte staerker entlasten
### P2-2: Login-Einstieg ist reich an Inhalt, aber nicht maximal fokussiert
Beobachtung:
- [NoLoginView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/home/NoLoginView.vue) erzaehlt Produkt und Zugang parallel.
- Fuer Erstnutzer ist die Seite informativ, aber die eigentliche Primaerhandlung konkurriert mit vielen Begleitinhalten.
Empfehlung:
- Zugangspfad noch klarer vom Story-Bereich trennen
- Login, Registrierung und Passwort-Reset als zusammenhaengenden Entscheidungsraum denken
### P2-3: Registrierung ist bedienbar, aber noch nicht robust genug gefuehrt
Beobachtung:
- [RegisterDialog.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/dialogues/auth/RegisterDialog.vue) ist funktional, aber nutzt wenig Hilfetext, kaum Inline-Validierung und schwer lesbare Fehlerpfade.
Empfehlung:
- Feldgruppen strukturieren
- Passwortlogik und Sprachwahl klarer erklaeren
- Fehler am Feld statt nur global rueckmelden
### P2-4: Forum hat guten Einstieg, aber zu wenig Orientierung im Schreiben-und-Lesen-Wechsel
Beobachtung:
- [ForumView.vue](/mnt/share/torsten/Programs/YourPart3/frontend/src/views/social/ForumView.vue) mischt Themenliste und Neuerstellung in einem einfachen Toggle.
- Es fehlt eine staerkere Fuehrung, wann man lesen und wann man schreiben soll.
Empfehlung:
- Schreibmodus staerker vom Lesemodus absetzen
- Entwurf, Abbruch und Rueckkehr klarer machen
### P2-5: Vokabeltrainer ist funktional breit, aber als Lernpfad noch nicht klar genug
Beobachtung:
- Anlegen, Abonnieren, Kapitel, Kurs, Lektion und Uebung sind einzeln verbessert, bilden aber noch keinen vollkommen klaren End-to-End-Lernpfad.
Empfehlung:
- Informationsarchitektur entlang von:
- entdecken
- beitreten
- lernen
- ueben
- bearbeiten
### P2-6: Falukant ist visuell stark, aber in taeglichen Routineablaeufen noch nicht effizient genug
Beobachtung:
- Statusleiste und Einstiege sind besser, aber Arbeitsablaeufe ueber mehrere Unterseiten bleiben kognitiv schwer.
Empfehlung:
- Top-5-Routinen definieren
- dafuer direkte Folgeaktionen aus Status und Uebersichten anbieten
### P2-7: Admin-Flaechen haben zu wenig gemeinsame Bedienlogik
Beobachtung:
- einzelne Admin-Seiten sind moderner, aber Such-, Editier- und Speichermuster unterscheiden sich weiterhin.
Empfehlung:
- gemeinsames Admin-Muster fuer Suche, Detailansicht, Editieren und Speichern definieren
### P2-8: Match3 hat gute Spieloberflaeche, aber Meta-Interaktion ist noch zu verstreut
Beobachtung:
- Spielstatus, Levelbeschreibung, Statistiken und Steuerung konkurrieren um Aufmerksamkeit.
Empfehlung:
- klare Prioritaet:
- Spielbrett
- aktuelles Ziel
- verbleibende Zuege
- Meta-Infos nur sekundär
### P2-9: Rueckwege sind nicht ueberall gleich gut sichtbar
Bereiche:
- Vokabel-Unterseiten
- Forum-Themen
- Falukant-Unterseiten
- Admin-Details
Empfehlung:
- gemeinsames Rueckwegmuster definieren
- nicht nur einzelne Buttons, sondern konsistente Bereichsorientierung
### P2-10: Leere Zustände sind nicht systematisch genug
Beobachtung:
- teilweise vorhanden, aber sehr uneinheitlich in Ton, Handlungsangebot und Sichtbarkeit.
Empfehlung:
- Standard fuer:
- keine Daten
- keine Treffer
- noch nicht gestartet
- keine Berechtigung
### P2-11: Mobile Nutzbarkeit ist verbessert, aber nicht abschließend entlang echter Kernaufgaben geprüft
Empfehlung:
- echter Geräte-/Viewport-Durchgang entlang der Kernszenarien
## P3-Probleme
### P3-1: Button-Semantik und Farblogik sind noch nicht vollkommen systematisch
Beobachtung:
- globale Buttons sind modernisiert, lokale Altvarianten bestehen weiter.
### P3-2: Teilweise alte Feld- und Listenmuster in Spezialbereichen
### P3-3: Headline-Hierarchien sind nicht in allen Ansichten gleich klar
### P3-4: Dialoginhalte wirken je nach Bereich unterschiedlich dicht
### P3-5: Tabellen haben uneinheitliche Leselogik und Aktionsplatzierung
### P3-6: In einigen Bereichen ist unklar, welche Aktion primaer und welche optional ist
### P3-7: Beta-/Systemhinweise koennen in Teilen ruhiger und weniger redundant werden
### P3-8: Einige Views nutzen sehr lange vertikale Inhaltsflaechen ohne Zwischenanker
### P3-9: Inline-Hilfen und Tooltips sind in komplexen Bereichen noch unterentwickelt
### P3-10: Touch-/Hover-Verhalten ist nicht in allen Spezialviews gleich robust
### P3-11: Message- und Error-Wording ist noch nicht konsistent
### P3-12: Manche Dialoge und Formulare sind auf Desktop gut, aber auf enger Breite nur ausreichend
### P3-13: Such- und Filterbereiche koennten staerker standardisiert werden
## P4-Punkte
### P4-1: Mikrointeraktionen in Karten, Listen und Toolbars weiter harmonisieren
### P4-2: Badge- und Statusdarstellungen semantisch weiter schaerfen
### P4-3: Fokus- und Hover-Zustaende in Spezialkomponenten weiter angleichen
### P4-4: Editorbereiche visuell und bedienlogisch weiter vereinheitlichen
### P4-5: Leichte Textkuerzungen fuer schnellere Scanbarkeit in Hero-Bereichen
### P4-6: Weitere Feinarbeit an Footer/Fensterleiste in langen Nutzungssessions
## Bereichsspezifische Kurzbewertung
### Shell und Navigation
- deutlich verbessert
- noch offen: inhaltliche Priorisierung, Rueckwege, Endabnahme kleiner Screens
### Einstieg ohne Login
- stark verbessert
- noch offen: Zugangspfad fokussierter gegen Story-Inhalt absetzen
### Registrierung/Login
- funktional ok
- UX-seitig noch zu wenig gefuehrt und rueckmeldearm
### Forum
- guter Ueberblick, aber Schreib-/Lesefluss noch nicht ideal getrennt
### Vokabeltrainer
- optisch konsistent, aber noch kein vollkommen klarer End-to-End-Lernpfad
### Falukant
- visuell stark, fachlich noch der anspruchsvollste Bedienbereich
### Admin
- einzelne Ansichten verbessert, gemeinsame Admin-Bedienlogik fehlt noch
### Minigames
- Match3 solide, aber Status-/Metaebene kann bedienlogisch weiter fokussiert werden
## Priorisierte Umsetzung nach U1
### Paket U1-A
- Feedbacksystem vereinheitlichen
- Formularvalidierung sichtbar machen
- Scroll- und Sticky-Logik historischer Sonderfaelle bereinigen
### Paket U1-B
- Navigation und Rueckwege nach Aufgaben priorisieren
- Leere Zustände und Systemzustände standardisieren
### Paket U1-C
- Vokabeltrainer-Lernpfad schärfen
- Falukant-Routinen entschlacken
- Admin-Bedienmuster vereinheitlichen
### Paket U1-D
- Mobile Kernaufgaben-Endabnahme
- Konsistenz-Feinschliff auf P3/P4-Niveau
## Fazit
Die App ist gestalterisch deutlich weiter als vor dem Redesign, aber bedienlogisch noch nicht auf demselben Reifegrad. Die größten UX-Hebel liegen nicht mehr in Farben oder Layout, sondern in:
- konsistentem Feedback
- klareren Aufgabenflüssen
- sichtbarerer Validierung
- entschlackten Fachbereichen
- sauberer Priorisierung von Aktionen
Phase U1 ist damit abgeschlossen. Die naechste sinnvolle Arbeitsphase ist `U2: Shell, Navigation und Feedback`.

401
docs/USABILITY_CONCEPT.md Normal file
View File

@@ -0,0 +1,401 @@
# Bedienbarkeitskonzept
## Ziel
Die Bedienbarkeit von YourPart soll systematisch verbessert werden, ohne die vorhandene Funktionsbreite oder den neuen UI-Stand wieder aufzubrechen. Der Fokus liegt auf Orientierung, Vorhersagbarkeit, Aufgabenfluss, Fehlertoleranz und effizienter Nutzung auf Desktop und Mobile.
Das Dokument ist bewusst als Arbeitsgrundlage aufgebaut:
- Was genau verbessert werden soll
- nach welchen Prinzipien entschieden wird
- in welcher Reihenfolge gearbeitet wird
- woran ein Punkt als erledigt gilt
## Leitprinzipien
### 1. Weniger Reibung
- Haeufige Aufgaben muessen mit moeglichst wenig Entscheidungen und moeglichst wenig Klicks erreichbar sein.
- Sekundaere Funktionen duerfen nicht die Kernaufgabe stoeren.
### 2. Klare Orientierung
- Nutzer muessen jederzeit erkennen:
- wo sie sich befinden
- was hier moeglich ist
- was der naechste sinnvolle Schritt ist
### 3. Konsistente Interaktion
- Gleiche Interaktionsmuster muessen sich in der gesamten App gleich verhalten.
- Buttons, Dialoge, Tabs, Listen, Formulare und Menues duerfen nicht je Bereich eigene Bedienlogiken entwickeln.
### 4. Fehlertoleranz statt Bestrafung
- Fehlbedienungen muessen auffangbar sein.
- Kritische Aktionen brauchen klare Rueckmeldung, wo sinnvoll Bestätigung und wenn moeglich Undo oder sichere Rueckwege.
### 5. Geschwindigkeit fuer geuebte Nutzer
- Power-User sollen die App schnell nutzen koennen.
- Haeufige Wege duerfen nicht durch uebermaessige Zwischenschritte ausgebremst werden.
## Nicht-Ziele
- Keine komplette Informationsarchitektur-Neuerfindung in einem Schritt.
- Kein sofortiger Umbau jedes Workflows gleichzeitig.
- Keine reine Accessibility-Checklistenarbeit ohne echten Nutzwert.
## Ausgangsprobleme
Aus dem aktuellen Projektstand und den bisherigen Umbauten ergeben sich fuer die Bedienbarkeit vor allem diese Problemklassen:
- Uneinheitliche Bedienmuster zwischen alten und neueren Bereichen
- zu viele tiefe Menues und bereichsspezifische Sonderlogiken
- einige Seiten mit hoher Funktionsdichte, aber schwacher Priorisierung
- Mischformen aus statischen Shell-Bereichen und historischen inneren Scrollkonzepten
- lokale Alt-Dialoge und Formmuster mit uneinheitlicher Rueckmeldung
- Desktop-zentrierte Denkweise in Teilen von Admin, Minigames und Falukant
## Zielbild
Am Ende soll die App folgendes Nutzungsgefuehl bieten:
- Shell, Navigation und Dialoge verhalten sich vorhersagbar
- jede Hauptseite zeigt klar eine Primaeraufgabe und erkennbare Sekundaeraufgaben
- Formulare fuehren sauber durch Eingabe, Validierung und Abschluss
- Statusaenderungen und Systemreaktionen sind sichtbar und verstaendlich
- Falukant, Community, Blog und Lernen bleiben fachlich unterschiedlich, aber bedienbar aus einem Guss
## Arbeitsbereiche
### Bereich A: Navigation und Orientierung
Ziel:
- Nutzer finden schneller zum Ziel und verstehen Menuehierarchien besser.
Arbeitspunkte:
- Hauptnavigation auf tatsaechliche Primaerbereiche pruefen
- Untermenues auf Redundanzen und Leerpfade pruefen
- aktive Position und Kontext pro Bereich klarer machen
- in tiefen Bereichen Rueckwege, Breadcrumb-artige Hinweise oder Bereichstitel konsistent gestalten
- Schnellzugriffe nur dort einsetzen, wo sie echte Beschleunigung bringen
Abnahmekriterien:
- jeder Hauptmenuepunkt hat einen klaren Zweck
- Menuepunkte ohne Untermenue verhalten sich direkt
- tiefe Ansichten haben einen klaren Rueckweg
- Nutzer muessen nicht raten, in welchem Bereich sie sich befinden
### Bereich B: Seitenhierarchie und Aufgabenfluss
Ziel:
- jede Seite hat einen erkennbaren Einstieg, eine Hauptaufgabe und einen lesbaren Aufbau
Arbeitspunkte:
- pro View Primaeraktion definieren
- Informationsdichte dort reduzieren, wo gleichrangige Bloecke konkurrieren
- visuelle Prioritaet zwischen Lesen, Auswaehlen, Bearbeiten und Absenden schaerfen
- Tabellen, Listen und Karten jeweils auf ihren eigentlichen Einsatzzweck pruefen
Abnahmekriterien:
- Nutzer erkennen in wenigen Sekunden den Zweck einer Seite
- Hauptaktionen sind oberhalb oder in unmittelbarer Naehe des relevanten Inhalts
- Nebenfunktionen dominieren die Seite nicht mehr
### Bereich C: Formulare und Eingaben
Ziel:
- Formulare sollen verstaendlich, fehlertolerant und effizient sein
Arbeitspunkte:
- Labels, Hilfetexte, Pflichtfelder und Fehlermeldungen vereinheitlichen
- Validierung naeher an den Eingabepunkt bringen
- unklare Zustandswechsel vermeiden
- Speichern, Abbrechen und gefaehrliche Aktionen konsistent platzieren
- Checkboxen, Radios und Selects auf Lesbarkeit und Trefferflaechen pruefen
Abnahmekriterien:
- jeder Eingabefehler ist erkennbar und nachvollziehbar
- Speichern fuehlt sich in allen Bereichen gleich an
- keine Form wirkt wie ein historischer Sonderfall
### Bereich D: Dialoge, Feedback und Systemstatus
Ziel:
- Systemreaktionen muessen sichtbar, verstaendlich und nicht stoerend sein
Arbeitspunkte:
- Dialogarten unterscheiden:
- bestaetigend
- informierend
- bearbeitend
- kritisch
- Messagebox/Dialog-Rueckmeldungen vereinheitlichen
- Ladezustaende, Erfolg, Fehler und Leere Zustaende standardisieren
- offene Dialoge und Fensterleiste auf Priorisierung pruefen
Abnahmekriterien:
- Nutzer verstehen, was gerade passiert ist oder noch passiert
- kritische Aktionen sind klar markiert
- Modale Interaktionen blockieren nur, wenn es wirklich noetig ist
### Bereich E: Mobile und kleine Viewports
Ziel:
- zentrale Aufgaben muessen auch auf kleineren Screens robust funktionieren
Arbeitspunkte:
- Shell mit Header, Navigation, Content und Footer auf reale Nutzungsszenarien pruefen
- Tabellen und breite Steuermasken auf mobile Alternativen oder horizontale Strategien prüfen
- Touch-Ziele und Abstaende angleichen
- Hover-abhaengige Muster fuer Touch absichern
Abnahmekriterien:
- Kernaufgaben sind auf Tablet und Smartphone ohne Layoutbruch nutzbar
- keine kritische Funktion ist nur via Hover oder Pixel-Präzision erreichbar
### Bereich F: Komplexe Produktbereiche
Ziel:
- Falukant, Vokabeltrainer, Minigames und Admin sollen fachlich komplex bleiben, aber leichter steuerbar werden
Arbeitspunkte:
- Falukant:
- Schnellzugriffsleiste, Tab-Struktur, Statusfeedback und Arbeitsablaeufe priorisieren
- Vokabeltrainer:
- Lernpfad, Bearbeitung, Suche, Uebung und Fortschritt klarer trennen
- Minigames:
- Einstieg, Pause, Statusanzeige und Kampagnenfluss vereinfachen
- Admin:
- Such-, Editier- und Bestaetigungsfluesse entlasten
Abnahmekriterien:
- komplexe Bereiche sind ohne Einlernen nicht sofort trivial, aber deutlich besser fuehrend
- wiederkehrende Aktionen sind schneller und sicherer bedienbar
## Methodik
### 1. Bedienbarkeits-Audit
Pro Hauptbereich wird ein kurzer Audit gemacht:
- Primaeraufgabe der Seite
- haeufigste Nutzeraktion
- groesste Reibung
- groesstes Fehlerrisiko
- mobile Schwachstelle
Empfohlene Cluster:
- Shell und Navigation
- Home und Einstieg
- Community/Social
- Blog
- Vokabeltrainer
- Falukant
- Admin
- Minigames
- Dialoge/Formulare
### 2. Aufgabenorientierte Review-Szenarien
Die App wird nicht nur nach Komponenten, sondern nach Aufgaben geprueft:
- registrieren und einloggen
- Profil/Freunde finden
- Forumsthema finden und beantworten
- Vokabelsprache erstellen, abonnieren, lernen
- Falukant-Status pruefen und Folgeaktion ausfuehren
- Admin-Nutzer suchen und aendern
- Match3 starten, pausieren, neu starten
### 3. Fix-Kategorien
Alle Probleme werden in vier Kategorien eingeordnet:
- P1: blockiert oder verwirrt Kernnutzung deutlich
- P2: verlangsamt Nutzung oder erzeugt Fehlbedienung
- P3: stoert Konsistenz oder Lesbarkeit
- P4: Feinschliff ohne unmittelbaren Schaden
## Umsetzungsphasen
### Phase U1: Audit und Problemkatalog
Ergebnis:
- kompakte Liste realer Bedienprobleme pro Hauptbereich
- priorisiert nach P1 bis P4
Arbeit:
- 1 Durchgang Desktop
- 1 Durchgang kleiner Viewport
- 1 Durchgang fuer Tastatur-/Dialognutzung
Aktueller Stand:
- abgeschlossen
- Audit dokumentiert in `docs/USABILITY_AUDIT_U1.md`
- priorisierte Folgephase: `U2 Shell, Navigation und Feedback`
### Phase U2: Shell, Navigation, Feedback
Ergebnis:
- globale Bedienmuster sind konsistent
Arbeit:
- Navigation
- Rueckwege
- Fokuslogik
- Dialog-/Feedbacksystem
- Lade- und Leerezustaende
Aktueller Stand:
- abgeschlossen
- Shell-Kontextbereich mit Bereichstitel und Rueckweg umgesetzt
- Navigation um klareren Seitenkontext ergaenzt
- zentrales Feedback-API eingefuehrt
- Standard-Feedbackdialoge visuell und technisch vereinheitlicht
- Kernfluesse aus Auth und Settings auf das neue Feedbackmuster umgestellt
### Phase U3: Formulare und Abschlussfluesse
Ergebnis:
- Eingaben, Speichern, Validierung und Rueckmeldung sind vereinheitlicht
Arbeit:
- Auth
- Settings
- Admin
- Falukant-Formulare
- Vokabel-Bearbeitungsfluesse
Aktueller Stand:
- abgeschlossen
- gemeinsames Formularmuster fuer Hinweise, Fehler und Action-Row eingefuehrt
- Dialogbuttons respektieren Disabled-Zustaende
- Auth-Dialoge, Account-Settings, zentrale Admin-/Falukant-Formulare und Vokabel-Bearbeitungsfluesse auf sichtbarere Validierung und konsistentere Abschlusslogik umgestellt
### Phase U4: Komplexe Fachbereiche
Ergebnis:
- Falukant, Vokabeltrainer, Minigames und Admin sind auf Nutzbarkeit statt nur Funktion geprüft
Arbeit:
- Arbeitsablaeufe entlasten
- Primaeraktionen schaerfen
- Informationslast reduzieren
Aktueller Stand:
- abgeschlossen
- Vokabeltrainer als Aufgabenhub mit getrennten Bereichen fuer eigene und abonnierte Sprachen
- Falukant-Uebersicht um Routinekarten, verdichtete Kennzahlen und schnellere Folgeaktionen erweitert
- Match3-Spiel um Ziel-/Statusleiste fuer den naechsten sinnvollen Schritt ergaenzt
- Match3-Admin um klaren 3-Schritt-Arbeitsfluss, Formzusammenfassung und sicherere Speicherlogik erweitert
### Phase U5: Mobile und Endabnahme
Ergebnis:
- Kernaufgaben sind auf Standard-Viewports belastbar
Arbeit:
- letzte Layoutkorrekturen
- Touch und Fokus
- Abschlussreview entlang echter Nutzerszenarien
Aktueller Stand:
- abgeschlossen
- Hauptnavigation auf kleinen Viewports zu einer verlässlich aufklappbaren Mobilnavigation mit Touch-gerechten Zielgroessen umgebaut
- Header und Footer auf kleine Breiten mit stabileren Status- und Linkblöcken angepasst
- Dialoge fuer kleine Viewports auf sichere Maximalgroessen und mobile Button-Stacks begrenzt
- breite Tabellen auf kleinen Screens per horizontalem Scroll-Fallback abgesichert
- globale Touch-Ziele fuer Buttons leicht vergroessert und letzte Shell-Kanten geglaettet
## Konkreter Arbeitskatalog
### 1. Shell und Navigation
- Menuepunkte auf Nutzungsprioritaet sortieren
- Untermenues auf direkte Zielerreichung pruefen
- Bereichskontext pro Seite konsistent machen
- globale Ruecksprunglogik definieren
### 2. Dialog- und Feedbacksystem
- Dialogtypen definieren und dokumentieren
- Standard fuer Erfolg, Fehler, Warnung, Leere, Laden festlegen
- Inline-Feedback vor modalem Feedback bevorzugen, wenn kein harter Block noetig ist
### 3. Formsystem
- ein gemeinsames Muster fuer Label, Hilfetext, Fehlermeldung, Pflichtfeld
- ein gemeinsames Muster fuer Save/Cancel/Delete
- ein gemeinsames Muster fuer Tabellenfilter und Suchformulare
### 4. Bereichsreviews
- Social/Friends/Search/Forum entlang echter Aufgaben pruefen
- Vokabeltrainer entlang des Lernpfads pruefen
- Falukant entlang taeglicher Routinen pruefen
- Admin entlang Such-/Editier-Routinen pruefen
- Minigames entlang Einstieg/Pause/Neustart pruefen
### 5. Mobile Review
- Header/Nav/Footer mit realen Hoehen pruefen
- breite Inhalte auf kleine Screens pruefen
- Dialoge und Tabellen fuer Touch pruefen
## Definition of Done
Das Bedienbarkeitsprojekt gilt als abgeschlossen, wenn:
- fuer alle Hauptbereiche ein Audit stattgefunden hat
- P1- und P2-Probleme aus dem Audit abgearbeitet sind
- Navigation, Formulare, Dialoge und Feedback nach gemeinsamen Regeln funktionieren
- Kernaufgaben auf Desktop und kleinem Viewport ohne strukturelle Reibung moeglich sind
- Restpunkte nur noch P3/P4-Feinschliff sind
## Empfohlene Reihenfolge
1. Audit ueber Kernaufgaben
2. Shell/Navigation/Feedback
3. Formulare und Abschlusslogik
4. Falukant, Vokabeltrainer, Admin, Minigames
5. Mobile Endabnahme
## Naechster konkreter Schritt
Der erste sinnvolle Umsetzungsschritt ist nicht sofort Code, sondern ein kurzer UX-Audit-Durchgang ueber die wichtigsten Aufgabenfluesse. Daraus entsteht ein priorisierter Problemkatalog, auf dessen Basis die Bedienbarkeitsarbeit strukturiert umgesetzt wird.

View File

@@ -45,9 +45,9 @@
--space-10: 40px;
--space-12: 48px;
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 24px;
--radius-sm: 5px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 999px;
--shadow-soft: 0 12px 30px rgba(47, 29, 14, 0.08);
@@ -118,7 +118,7 @@ span.button {
align-items: center;
justify-content: center;
gap: var(--space-2);
min-height: 42px;
min-height: 44px;
padding: 0 18px;
border: 1px solid transparent;
border-radius: var(--radius-pill);
@@ -147,11 +147,23 @@ span.button:active {
transform: translateY(0);
}
button:disabled,
.button:disabled,
span.button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
a:focus-visible {
a:focus-visible,
[role="button"]:focus-visible,
[role="menuitem"]:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(120, 195, 138, 0.32);
outline-offset: 2px;
}
@@ -263,11 +275,72 @@ main,
overflow: auto;
}
.app-content__inner > .contenthidden {
height: auto;
overflow: visible;
}
.app-content__inner > .contenthidden > .contentscroll {
height: auto;
overflow: visible;
}
.surface-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.form-stack {
display: grid;
gap: 14px;
}
.form-field {
display: grid;
gap: 8px;
}
.form-field > label,
.form-field > span:first-child {
font-weight: 600;
color: var(--color-text-secondary);
}
.form-hint {
font-size: 0.88rem;
color: var(--color-text-muted);
}
.form-error {
font-size: 0.88rem;
color: var(--color-danger);
}
.field-error {
border-color: rgba(177, 59, 53, 0.44) !important;
box-shadow: 0 0 0 4px rgba(177, 59, 53, 0.12) !important;
}
.form-actions-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.button-secondary {
background: rgba(255, 255, 255, 0.86);
color: var(--color-text-primary);
border-color: var(--color-border);
box-shadow: none;
}
.button-secondary:hover {
background: rgba(255, 255, 255, 0.96);
box-shadow: none;
}
.link {
@@ -338,4 +411,27 @@ main,
h2 {
font-size: clamp(1.35rem, 5vw, 2rem);
}
.contentscroll table {
display: block;
width: 100%;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
}
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

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

View File

@@ -1,18 +1,31 @@
<template>
<footer class="app-footer">
<div class="app-footer__inner">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
</button>
<div class="window-bar">
<button v-for="dialog in openDialogs" :key="dialog.dialog.name" class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)" :title="dialog.dialog.localTitle">
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
<div class="footer-system">
<button class="footer-brand" type="button" @click="showFalukantDaemonStatus">
<img src="/images/icons/logo_color.png" alt="YourPart" />
<span>System</span>
</button>
<span class="footer-caption">
{{ openDialogs.length === 0 ? 'Keine offenen Dialoge' : `${openDialogs.length} Fenster aktiv` }}
</span>
</div>
<div class="window-bar" :class="{ 'window-bar--empty': openDialogs.length === 0 }">
<button
v-for="dialog in openDialogs"
:key="dialog.dialog.name"
class="dialog-button"
@click="toggleDialogMinimize(dialog.dialog.name)"
:title="dialog.dialog.localTitle"
>
<img v-if="dialog.dialog.icon" :src="'/images/icons/' + dialog.dialog.icon" />
<span class="button-text">{{ dialog.dialog.isTitleTranslated ? $t(dialog.dialog.localTitle) :
dialog.dialog.localTitle }}</span>
</button>
<span v-if="openDialogs.length === 0" class="window-bar__empty">System bereit</span>
</div>
<div class="static-block">
<a href="#" @click.prevent="openImprintDialog">{{ $t('imprint.button') }}</a>
<a href="#" @click.prevent="openDataPrivacyDialog">{{ $t('dataPrivacy.button') }}</a>
@@ -88,6 +101,12 @@ export default {
box-shadow: 0 -6px 18px rgba(93, 64, 55, 0.06);
}
.footer-system {
display: flex;
align-items: center;
gap: 10px;
}
.footer-brand {
min-height: 32px;
padding: 0 10px 0 8px;
@@ -111,6 +130,12 @@ export default {
font-weight: 700;
}
.footer-caption {
font-size: 0.76rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.window-bar {
flex: 1;
display: flex;
@@ -118,6 +143,20 @@ export default {
justify-content: flex-start;
gap: 10px;
overflow: auto;
min-width: 0;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
border: 1px solid rgba(120, 195, 138, 0.16);
}
.window-bar--empty {
justify-content: center;
}
.window-bar__empty {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.dialog-button {
@@ -151,6 +190,8 @@ export default {
align-items: center;
gap: 18px;
white-space: nowrap;
padding-left: 8px;
border-left: 1px solid rgba(120, 195, 138, 0.22);
}
.static-block>a {
@@ -167,14 +208,48 @@ export default {
flex-wrap: wrap;
}
.footer-system,
.window-bar,
.static-block {
width: 100%;
}
.footer-system {
justify-content: space-between;
}
.static-block {
justify-content: space-between;
gap: 12px;
padding-left: 0;
border-left: 0;
border-top: 1px solid rgba(120, 195, 138, 0.2);
padding-top: 6px;
}
}
@media (max-width: 640px) {
.app-footer__inner {
gap: 10px;
padding: 8px 10px 10px;
}
.footer-system {
flex-wrap: wrap;
}
.window-bar {
border-radius: var(--radius-md);
padding: 6px;
}
.dialog-button {
min-height: 34px;
}
.static-block {
flex-wrap: wrap;
justify-content: flex-start;
}
}
</style>

View File

@@ -5,11 +5,13 @@
<div class="logo"><img src="/images/logos/logo.png" alt="YourPart" /></div>
<div class="brand-copy">
<strong>YourPart</strong>
<span>Community, Spiele und Lernen auf einer Plattform</span>
<span>Community-Plattform</span>
</div>
</div>
<div class="header-meta">
<div class="header-pill">Beta</div>
<div class="header-meta__context">
<span class="header-pill">Beta</span>
</div>
<div class="connection-status" v-if="isLoggedIn">
<div class="status-indicator" :class="backendStatusClass">
<span class="status-dot"></span>
@@ -56,12 +58,12 @@ export default {
.app-header {
position: relative;
flex: 0 0 auto;
padding: 8px 14px;
padding: 6px 14px;
background:
linear-gradient(180deg, rgba(255, 248, 236, 0.94) 0%, rgba(247, 235, 216, 0.98) 100%);
linear-gradient(180deg, rgba(255, 249, 240, 0.96) 0%, rgba(246, 236, 220, 0.98) 100%);
color: #2b1f14;
border-bottom: 1px solid rgba(93, 64, 55, 0.12);
box-shadow: 0 6px 18px rgba(93, 64, 55, 0.08);
box-shadow: 0 5px 14px rgba(93, 64, 55, 0.06);
}
.app-header__inner {
@@ -77,15 +79,16 @@ export default {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.logo {
width: 42px;
height: 42px;
padding: 6px;
border-radius: 14px;
width: 40px;
height: 40px;
padding: 5px;
border-radius: 12px;
background:
linear-gradient(180deg, rgba(248, 162, 43, 0.2) 0%, rgba(255, 255, 255, 0.7) 100%);
linear-gradient(180deg, rgba(248, 162, 43, 0.18) 0%, rgba(255, 255, 255, 0.76) 100%);
border: 1px solid rgba(248, 162, 43, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
}
@@ -99,18 +102,22 @@ export default {
.brand-copy {
display: flex;
flex-direction: column;
gap: 1px;
gap: 0;
min-width: 0;
}
.brand-copy strong {
font-size: 1.05rem;
font-size: 1rem;
line-height: 1.1;
color: #3a2a1b;
}
.brand-copy span {
font-size: 0.8rem;
color: rgba(95, 75, 57, 0.88);
font-size: 0.74rem;
color: rgba(95, 75, 57, 0.78);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-meta {
@@ -119,10 +126,16 @@ export default {
gap: 12px;
}
.header-meta__context {
display: flex;
align-items: center;
gap: 10px;
}
.header-pill {
padding: 5px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
background: rgba(248, 162, 43, 0.14);
border: 1px solid rgba(248, 162, 43, 0.24);
font-size: 0.72rem;
font-weight: 700;
@@ -205,17 +218,46 @@ export default {
}
.app-header__inner {
gap: 8px;
gap: 10px;
flex-wrap: wrap;
}
.header-meta {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
.header-meta__context {
flex-wrap: wrap;
}
.brand-copy span {
font-size: 0.76rem;
white-space: normal;
}
}
@media (max-width: 640px) {
.app-header__inner {
align-items: flex-start;
}
.brand {
width: 100%;
}
.header-meta {
gap: 8px;
}
.connection-status {
width: 100%;
flex-wrap: wrap;
}
.status-indicator {
min-height: 32px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,198 @@
<template>
<section v-if="isVisible" class="app-section-bar surface-card">
<div class="app-section-bar__copy">
<span class="app-section-bar__eyebrow">{{ sectionLabel }}</span>
<h1 class="app-section-bar__title">{{ pageTitle }}</h1>
</div>
<button
v-if="backTarget"
type="button"
class="app-section-bar__back"
@click="navigateBack"
>
Zurueck
</button>
</section>
</template>
<script>
const SECTION_LABELS = [
{ test: (path) => path.startsWith('/falukant'), label: 'Falukant' },
{ test: (path) => path.startsWith('/socialnetwork/vocab'), label: 'Vokabeltrainer' },
{ test: (path) => path.startsWith('/socialnetwork/forum'), label: 'Forum' },
{ test: (path) => path.startsWith('/socialnetwork'), label: 'Community' },
{ test: (path) => path.startsWith('/friends'), label: 'Community' },
{ test: (path) => path.startsWith('/settings'), label: 'Einstellungen' },
{ test: (path) => path.startsWith('/admin'), label: 'Administration' },
{ test: (path) => path.startsWith('/minigames'), label: 'Minispiele' },
{ test: (path) => path.startsWith('/personal'), label: 'Persoenlich' },
{ test: (path) => path.startsWith('/blogs'), label: 'Blog' }
];
const TITLE_MAP = {
Friends: 'Freunde',
Guestbook: 'Gaestebuch',
'Search users': 'Suche',
Gallery: 'Galerie',
Forum: 'Forum',
ForumTopic: 'Thema',
Diary: 'Tagebuch',
VocabTrainer: 'Sprachen',
VocabNewLanguage: 'Neue Sprache',
VocabSubscribe: 'Sprache abonnieren',
VocabLanguage: 'Sprache',
VocabChapter: 'Kapitel',
VocabCourses: 'Kurse',
VocabCourse: 'Kurs',
VocabLesson: 'Lektion',
FalukantCreate: 'Charakter erstellen',
FalukantOverview: 'Uebersicht',
BranchView: 'Niederlassung',
MoneyHistoryView: 'Geldverlauf',
FalukantFamily: 'Familie',
HouseView: 'Haus',
NobilityView: 'Adel',
ReputationView: 'Ansehen',
ChurchView: 'Kirche',
EducationView: 'Bildung',
BankView: 'Bank',
DirectorView: 'Direktoren',
HealthView: 'Gesundheit',
PoliticsView: 'Politik',
UndergroundView: 'Untergrund',
'Personal settings': 'Persoenliche Daten',
'View settings': 'Ansicht',
'Sexuality settings': 'Sexualitaet',
'Flirt settings': 'Flirt',
'Account settings': 'Account',
Interests: 'Interessen',
AdminInterests: 'Interessenverwaltung',
AdminUsers: 'Benutzer',
AdminUserStatistics: 'Benutzerstatistik',
AdminContacts: 'Kontaktanfragen',
AdminUserRights: 'Rechte',
AdminForums: 'Forumverwaltung',
AdminChatRooms: 'Chaträume',
AdminFalukantEditUserView: 'Falukant-Nutzer',
AdminFalukantMapRegionsView: 'Falukant-Karte',
AdminFalukantCreateNPCView: 'NPC erstellen',
AdminMinigames: 'Match3-Verwaltung',
AdminTaxiTools: 'Taxi-Tools',
AdminServicesStatus: 'Service-Status'
};
export default {
name: 'AppSectionBar',
computed: {
routePath() {
return this.$route?.path || '';
},
isVisible() {
return Boolean(this.$route?.meta?.requiresAuth) && this.routePath !== '/';
},
sectionLabel() {
const found = SECTION_LABELS.find((entry) => entry.test(this.routePath));
return found?.label || 'Bereich';
},
pageTitle() {
return TITLE_MAP[this.$route?.name] || this.sectionLabel;
},
backTarget() {
const params = this.$route?.params || {};
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.lessonId && params.courseId) {
return `/socialnetwork/vocab/courses/${params.courseId}`;
}
if (this.routePath.startsWith('/socialnetwork/vocab/') && params.chapterId && params.languageId) {
return `/socialnetwork/vocab/${params.languageId}`;
}
if (this.routePath.startsWith('/socialnetwork/vocab/new') || this.routePath.startsWith('/socialnetwork/vocab/subscribe')) {
return '/socialnetwork/vocab';
}
if (this.routePath.startsWith('/socialnetwork/vocab/courses/') && params.courseId) {
return '/socialnetwork/vocab/courses';
}
if (this.routePath.startsWith('/admin/users/statistics')) {
return '/admin/users';
}
if (this.routePath.startsWith('/falukant/') && this.routePath !== '/falukant/home') {
return '/falukant/home';
}
if (this.routePath.startsWith('/settings/') && this.routePath !== '/settings/personal') {
return '/settings/personal';
}
if (this.routePath.startsWith('/admin/') && this.routePath !== '/admin/users') {
return '/admin/users';
}
return null;
}
},
methods: {
navigateBack() {
if (this.backTarget) {
this.$router.push(this.backTarget);
}
}
}
};
</script>
<style scoped>
.app-section-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
margin-bottom: 16px;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.98), rgba(248, 240, 231, 0.94));
}
.app-section-bar__copy {
min-width: 0;
}
.app-section-bar__eyebrow {
display: inline-flex;
margin-bottom: 6px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.app-section-bar__title {
margin: 0;
font-size: clamp(1.15rem, 1.6vw, 1.6rem);
}
.app-section-bar__back {
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.82);
box-shadow: none;
border: 1px solid var(--color-border);
}
@media (max-width: 760px) {
.app-section-bar {
flex-direction: column;
align-items: flex-start;
}
.app-section-bar__back {
width: 100%;
}
}
</style>

View File

@@ -12,13 +12,27 @@
<script>
import { markRaw } from 'vue';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { getApiBaseURL } from '@/utils/axios.js';
/** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */
const MODELS_API_PATH = '/api/models/3d/falukant/characters';
let threeRuntimePromise = null;
async function loadThreeRuntime() {
if (!threeRuntimePromise) {
threeRuntimePromise = Promise.all([
import('three'),
import('three/addons/loaders/GLTFLoader.js'),
import('three/addons/loaders/DRACOLoader.js')
]).then(([THREE, { GLTFLoader }, { DRACOLoader }]) => ({
THREE,
GLTFLoader,
DRACOLoader
}));
}
return threeRuntimePromise;
}
export default {
name: 'Character3D',
@@ -48,9 +62,10 @@ export default {
model: null,
animationId: null,
mixer: null,
clock: markRaw(new THREE.Clock()),
clock: null,
baseYPosition: 0,
showFallback: false
showFallback: false,
threeRuntime: null
};
},
computed: {
@@ -110,32 +125,42 @@ export default {
}
},
watch: {
actualGender() {
this.loadModel();
async actualGender() {
await this.loadModel();
},
ageGroup() {
this.loadModel();
async ageGroup() {
await this.loadModel();
}
},
mounted() {
this.init3D();
this.loadModel();
async mounted() {
await this.init3D();
await this.loadModel();
this.animate();
},
beforeUnmount() {
this.cleanup();
},
methods: {
init3D() {
async ensureThreeRuntime() {
if (!this.threeRuntime) {
this.threeRuntime = markRaw(await loadThreeRuntime());
}
return this.threeRuntime;
},
async init3D() {
const container = this.$refs.container;
if (!container) return;
this.showFallback = false;
const { THREE } = await this.ensureThreeRuntime();
this.clock = markRaw(new THREE.Clock());
// Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
this.scene = markRaw(new THREE.Scene());
if (!this.noBackground) {
this.scene.background = new THREE.Color(0xf0f0f0);
this.loadBackground();
await this.loadBackground();
}
// Camera erstellen
@@ -174,7 +199,8 @@ export default {
window.addEventListener('resize', this.onWindowResize);
},
loadBackground() {
async loadBackground() {
const { THREE } = await this.ensureThreeRuntime();
// Optimierte Versionen (512×341, ~130 KB); Originale ~3 MB
const backgrounds = ['bg1_opt.png', 'bg2_opt.png'];
const randomBg = backgrounds[Math.floor(Math.random() * backgrounds.length)];
@@ -202,6 +228,7 @@ export default {
async loadModel() {
if (!this.scene) return;
const { THREE, GLTFLoader, DRACOLoader } = await this.ensureThreeRuntime();
// Altes Modell entfernen
if (this.model) {
@@ -323,6 +350,10 @@ export default {
animate() {
this.animationId = requestAnimationFrame(this.animate);
if (!this.clock) {
return;
}
const delta = this.clock.getDelta();
// Animation-Mixer aktualisieren

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@
<script>
import { mapState, mapGetters } from "vuex";
import apiClient from "@/utils/axios.js";
import { EventBus } from '@/utils/eventBus.js';
import MessagesDialog from './MessagesDialog.vue';
export default {
@@ -94,10 +95,12 @@ export default {
// Socket.IO (Backend notifyUser) Hauptkanal für Falukant-Events
this.setupSocketListeners();
this.setupDaemonListeners();
EventBus.on('open-falukant-messages', this.openMessages);
},
beforeUnmount() {
this.teardownSocketListeners();
this.teardownDaemonListeners();
EventBus.off('open-falukant-messages', this.openMessages);
},
methods: {
preloadQuickAccessImages() {
@@ -229,18 +232,20 @@ export default {
justify-content: center;
align-items: center;
flex-wrap: wrap;
background-color: #f4f4f4;
border: 1px solid #ccc;
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(247, 238, 224, 0.98) 100%);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-sizing: border-box;
width: 100%;
max-width: 100%;
gap: 1.2em;
padding: 0.4rem 0.75rem;
padding: 0.55rem 0.9rem;
margin: 0 0 1.5em 0;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-soft);
}
.status-item {
@@ -248,6 +253,11 @@ export default {
cursor: pointer;
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 10px;
border-radius: 999px;
background: rgba(255,255,255,0.68);
border: 1px solid rgba(93, 64, 55, 0.08);
}
.quick-access {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -497,6 +497,7 @@
"gifts": "Werbegeschenke",
"sendGift": "Werbegeschenk senden",
"cancel": "Werbung abbrechen",
"cancelConfirm": "Willst du die Werbung wirklich abbrechen? Der Fortschritt geht dabei verloren.",
"cancelSuccess": "Die Werbung wurde abgebrochen.",
"cancelError": "Die Werbung konnte nicht abgebrochen werden.",
"cancelTooSoon": "Du kannst die Werbung erst nach 24 Stunden abbrechen.",

View File

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

View File

@@ -481,6 +481,7 @@
"gifts": "Regalos de cortejo",
"sendGift": "Enviar regalo",
"cancel": "Cancelar el cortejo",
"cancelConfirm": "¿Seguro que quieres cancelar el cortejo? Se perderá el progreso.",
"cancelSuccess": "El cortejo se ha cancelado.",
"cancelError": "No se pudo cancelar el cortejo.",
"cancelTooSoon": "Solo puedes cancelar el cortejo después de 24 horas.",

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import ActivateView from '../views/auth/ActivateView.vue';
const ActivateView = () => import('../views/auth/ActivateView.vue');
const authRoutes = [
{

View File

@@ -1,6 +1,6 @@
import BlogListView from '@/views/blog/BlogListView.vue';
import BlogView from '@/views/blog/BlogView.vue';
import BlogEditorView from '@/views/blog/BlogEditorView.vue';
const BlogListView = () => import('@/views/blog/BlogListView.vue');
const BlogView = () => import('@/views/blog/BlogView.vue');
const BlogEditorView = () => import('@/views/blog/BlogEditorView.vue');
import { buildAbsoluteUrl } from '@/utils/seo.js';
export default [

View File

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

View File

@@ -1,6 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router';
import store from '../store';
import HomeView from '../views/HomeView.vue';
import authRoutes from './authRoutes';
import socialRoutes from './socialRoutes';
import settingsRoutes from './settingsRoutes';
@@ -12,6 +11,8 @@ import personalRoutes from './personalRoutes';
import marketingRoutes from './marketingRoutes';
import { applyRouteSeo, buildAbsoluteUrl } from '../utils/seo';
const HomeView = () => import('../views/HomeView.vue');
const routes = [
{
path: '/',

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
function getVm(context) {
if (!context) return null;
return context.proxy || context;
}
function getRootRefs(context) {
const vm = getVm(context);
return vm?.$root?.$refs || {};
}
function normalizeMessage(message, fallback = 'tr:error.network') {
if (!message) {
return fallback;
}
if (typeof message === 'string') {
return message;
}
return fallback;
}
export function showMessage(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
const refs = getRootRefs(context);
refs.messageDialog?.open?.(normalizeMessage(message, 'tr:message.title'), title, parameters, onClose);
}
export function showSuccess(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
showMessage(context, message, title, parameters, onClose);
}
export function showInfo(context, message, title = 'tr:message.title', parameters = {}, onClose = null) {
showMessage(context, message, title, parameters, onClose);
}
export function showError(context, message, fallback = 'tr:error.network') {
const refs = getRootRefs(context);
refs.errorDialog?.open?.(normalizeMessage(message, fallback));
}
export function showApiError(context, error, fallback = 'tr:error.network') {
const responseError = error?.response?.data?.error;
if (typeof responseError === 'string') {
const normalized = responseError.startsWith('tr:') || responseError.includes(' ')
? responseError
: `tr:error.${responseError}`;
showError(context, normalized, fallback);
return;
}
showError(context, fallback, fallback);
}
export default {
install(app) {
const getAppContext = () => app._instance?.proxy;
app.config.globalProperties.$feedback = {
showMessage: (...args) => showMessage(getAppContext(), ...args),
showSuccess: (...args) => showSuccess(getAppContext(), ...args),
showInfo: (...args) => showInfo(getAppContext(), ...args),
showError: (...args) => showError(getAppContext(), ...args),
showApiError: (...args) => showApiError(getAppContext(), ...args)
};
}
};

View File

@@ -3,9 +3,11 @@
</template>
<script>
import { defineAsyncComponent } from 'vue';
import { mapGetters } from 'vuex';
import HomeNoLoginView from './home/NoLoginView.vue';
import HomeLoggedInView from './home/LoggedInView.vue';
const HomeNoLoginView = defineAsyncComponent(() => import('./home/NoLoginView.vue'));
const HomeLoggedInView = defineAsyncComponent(() => import('./home/LoggedInView.vue'));
export default {
name: 'HomeView',
@@ -20,4 +22,4 @@ export default {
}
}
};
</script>
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,43 @@
<template>
<div class="blog-view">
<div v-if="loading">Laden</div>
<div v-else>
<h1>{{ blog.title }}</h1>
<p v-if="blog.description">{{ blog.description }}</p>
<div class="meta">von {{ blog.owner?.username }}</div>
<div v-if="$store.getters.isLoggedIn" class="actions">
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
</div>
<div class="posts">
<h2>{{ $t('blog.posts') }}</h2>
<div v-if="!items.length">{{ $t('blog.noPosts') }}</div>
<article v-for="p in items" :key="p.id" class="post">
<h3>{{ p.title }}</h3>
<div class="content" v-html="sanitize(p.content)" />
</article>
<div class="pagination" v-if="total > pageSize">
<div v-if="loading" class="blog-view__state surface-card">Laden</div>
<div v-else-if="blog" class="blog-layout">
<section class="blog-hero surface-card">
<div>
<div class="meta">von {{ blog.owner?.username }}</div>
<h1>{{ blog.title }}</h1>
<p v-if="blog.description" class="blog-description">{{ blog.description }}</p>
</div>
<div v-if="$store.getters.isLoggedIn" class="actions">
<router-link class="editbutton" v-if="isOwner" :to="{ name: 'BlogEdit', params: { id: blog.id } }">Bearbeiten</router-link>
</div>
</section>
<div class="blog-content">
<section class="posts surface-card">
<div class="posts__header">
<h2>{{ $t('blog.posts') }}</h2>
<span class="posts__count">{{ total }} Eintraege</span>
</div>
<div v-if="!items.length" class="blog-view__state">Keine Eintraege vorhanden.</div>
<article v-for="p in items" :key="p.id" class="post">
<h3>{{ p.title }}</h3>
<div class="content" v-html="sanitize(p.content)" />
</article>
<div class="pagination" v-if="total > pageSize">
<button :disabled="page===1" @click="go(page-1)">«</button>
<span>{{ page }} / {{ pages }}</span>
<button :disabled="page===pages" @click="go(page+1)">»</button>
</div>
</section>
<div v-if="isOwner" class="post-editor surface-card">
<h3>{{ $t('blog.newPost') }}</h3>
<form @submit.prevent="addPost">
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
<RichTextEditor v-model="newPost.content" :blog-id="blog.id" />
<button class="btn" type="submit">{{ $t('blog.publish') }}</button>
</form>
</div>
</div>
<div v-if="isOwner" class="post-editor">
<h3>{{ $t('blog.newPost') }}</h3>
<form @submit.prevent="addPost">
<input v-model="newPost.title" :placeholder="$t('blog.title')" required />
<RichTextEditor v-model="newPost.content" :blog-id="blog.id" />
<button class="btn" type="submit">{{ $t('blog.publish') }}</button>
</form>
</div>
</div>
</div>
</template>
@@ -169,12 +178,109 @@ export default {
</script>
<style lang="scss" scoped>
.blog-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.blog-layout {
display: grid;
gap: 18px;
}
.blog-hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
padding: 26px;
}
.blog-description {
margin: 0;
max-width: 70ch;
color: var(--color-text-secondary);
}
.meta {
margin-bottom: 10px;
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.blog-content {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(280px, 0.95fr);
gap: 18px;
align-items: start;
}
.posts,
.post-editor {
padding: 24px;
}
.posts__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.posts__count {
color: var(--color-text-muted);
font-size: 0.82rem;
font-weight: 700;
}
.post + .post {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--color-border);
}
.content {
color: var(--color-text-secondary);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
.blog-view__state {
padding: 24px;
text-align: center;
color: var(--color-text-secondary);
}
.editbutton {
border: 1px solid #000;
background-color: #f9a22c;
margin-bottom: 1em;
border-radius: 3px;
padding: 0.2em 0.5em;
margin-bottom: 0;
display: inline-block;
}
@media (max-width: 960px) {
.blog-hero,
.blog-content {
grid-template-columns: 1fr;
display: grid;
}
.blog-hero {
padding: 20px;
}
.posts,
.post-editor {
padding: 18px;
}
}
</style>

View File

@@ -2,7 +2,14 @@
<div class="contenthidden">
<StatusBar ref="statusBar" />
<div class="contentscroll">
<h2>{{ $t('falukant.branch.title') }}</h2>
<div class="falukant-branch">
<section class="branch-hero surface-card">
<div>
<span class="branch-kicker">Niederlassung</span>
<h2>{{ $t('falukant.branch.title') }}</h2>
<p>Produktion, Lager, Verkauf und Transport in einer spielweltbezogenen Steuerflaeche.</p>
</div>
</section>
<BranchSelection
:branches="branches"
@@ -308,6 +315,7 @@
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1110,8 +1118,49 @@ export default {
</script>
<style scoped lang="scss">
h2 {
padding-top: 20px;
.falukant-branch {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.branch-hero {
padding: 24px 26px;
margin-bottom: 16px;
background:
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
}
.branch-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.branch-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.branch-tab-content {
margin-top: 16px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 252, 247, 0.86);
box-shadow: var(--shadow-soft);
}
.branch-tab-pane {
min-height: 0;
}
.send-all-vehicles {
@@ -1161,11 +1210,12 @@ h2 {
}
.modal-content {
background: white;
background: rgba(255,255,255,0.98);
padding: 2rem;
border-radius: 8px;
border-radius: var(--radius-lg);
min-width: 400px;
max-width: 600px;
box-shadow: var(--shadow-medium);
}
.send-vehicle-form {

View File

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

View File

@@ -1,7 +1,46 @@
<template>
<div>
<div class="falukant-overview">
<StatusBar />
<h2>{{ $t('falukant.overview.title') }}</h2>
<section class="falukant-hero surface-card">
<div>
<span class="falukant-kicker">Falukant</span>
<h2>{{ $t('falukant.overview.title') }}</h2>
<p>Dein Stand in Wirtschaft, Familie und Besitz in einer verdichteten Uebersicht.</p>
</div>
</section>
<section v-if="falukantUser?.character" class="falukant-summary-grid">
<article class="summary-card surface-card">
<span class="summary-card__label">Niederlassungen</span>
<strong>{{ branchCount }}</strong>
<p>Direkter Zugriff auf deine wichtigsten Geschaeftsstandorte.</p>
</article>
<article class="summary-card surface-card">
<span class="summary-card__label">Produktionen aktiv</span>
<strong>{{ productionCount }}</strong>
<p>Laufende Produktionen, die zeitnah Abschluss oder Kontrolle brauchen.</p>
</article>
<article class="summary-card surface-card">
<span class="summary-card__label">Lagerpositionen</span>
<strong>{{ stockEntryCount }}</strong>
<p>Verdichteter Blick auf Warenbestand ueber alle Regionen.</p>
</article>
</section>
<section v-if="falukantUser?.character" class="falukant-routine-grid">
<article
v-for="action in routineActions"
:key="action.title"
class="routine-card surface-card"
>
<span class="routine-card__eyebrow">{{ action.kicker }}</span>
<h3>{{ action.title }}</h3>
<p>{{ action.description }}</p>
<button type="button" :class="action.secondary ? 'button-secondary' : ''" @click="openRoute(action.route)">
{{ action.cta }}
</button>
</article>
</section>
<!-- Erben-Auswahl wenn kein Charakter vorhanden -->
<div v-if="!falukantUser?.character" class="heir-selection-container">
@@ -136,6 +175,7 @@
import StatusBar from '@/components/falukant/StatusBar.vue';
import Character3D from '@/components/Character3D.vue';
import apiClient from '@/utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js';
import { mapState } from 'vuex';
const AVATAR_POSITIONS = {
@@ -233,6 +273,50 @@ export default {
const m = this.falukantUser?.money;
return typeof m === 'string' ? parseFloat(m) : m;
},
branchCount() {
return this.falukantUser?.branches?.length || 0;
},
productionCount() {
return this.productions.length;
},
stockEntryCount() {
return this.allStock.length;
},
routineActions() {
return [
{
kicker: 'Routine',
title: 'Niederlassung oeffnen',
description: 'Die schnellste Route zu Produktion, Lager, Verkauf und Transport.',
cta: 'Zu den Betrieben',
route: 'BranchView',
},
{
kicker: 'Ueberblick',
title: 'Finanzen pruefen',
description: 'Kontostand, Verlauf und wirtschaftliche Entwicklung ohne lange Suche.',
cta: 'Geldhistorie',
route: 'MoneyHistoryView',
secondary: true,
},
{
kicker: 'Charakter',
title: 'Familie und Nachfolge',
description: 'Wichtige persoenliche Entscheidungen und Haushaltsstatus gesammelt.',
cta: 'Familie oeffnen',
route: 'FalukantFamily',
secondary: true,
},
{
kicker: 'Besitz',
title: 'Haus und Umfeld',
description: 'Wohnsitz und alltaeglicher Status als eigener Arbeitsbereich.',
cta: 'Zum Haus',
route: 'HouseView',
secondary: true,
},
];
},
locale() {
return window.navigator.language || 'en-US';
},
@@ -369,6 +453,16 @@ export default {
openBranch(branchId) {
this.$router.push({ name: 'BranchView', params: { branchId } });
},
openRoute(routeName) {
if (routeName === 'BranchView') {
const firstBranch = this.falukantUser?.branches?.[0];
if (firstBranch?.id) {
this.openBranch(firstBranch.id);
}
return;
}
this.$router.push({ name: routeName });
},
async fetchProductions() {
try {
const response = await apiClient.get('/api/falukant/productions');
@@ -399,15 +493,15 @@ export default {
async selectHeir(heirId) {
try {
await apiClient.post('/api/falukant/heirs/select', { heirId });
// Lade User-Daten neu
await this.fetchFalukantUser();
if (this.falukantUser?.character) {
await this.fetchAllStock();
await this.fetchProductions();
}
showSuccess(this, 'Erbe wurde uebernommen.');
} catch (error) {
console.error('Error selecting heir:', error);
alert(this.$t('falukant.overview.heirSelection.error'));
showError(this, this.$t('falukant.overview.heirSelection.error'));
}
},
},
@@ -415,16 +509,99 @@ export default {
</script>
<style scoped lang="scss">
.falukant-overview {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.falukant-hero {
padding: 24px 26px;
margin-bottom: 16px;
background:
radial-gradient(circle at top right, rgba(248, 162, 43, 0.16), transparent 28%),
linear-gradient(180deg, rgba(255, 250, 243, 0.98) 0%, rgba(247, 238, 224, 0.98) 100%);
}
.falukant-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.falukant-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.falukant-summary-grid,
.falukant-routine-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.falukant-routine-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.summary-card,
.routine-card {
padding: 18px;
}
.summary-card strong {
display: block;
margin: 6px 0 8px;
font-size: 1.8rem;
line-height: 1;
}
.summary-card p,
.routine-card p {
margin: 0;
color: var(--color-text-secondary);
}
.summary-card__label,
.routine-card__eyebrow {
display: inline-flex;
margin-bottom: 4px;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.routine-card h3 {
margin: 0 0 8px;
}
.routine-card button {
margin-top: 14px;
}
.overviewcontainer {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 5px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.overviewcontainer>div {
border: 1px solid #ccc;
padding: 5px;
border-radius: 4px;
border: 1px solid var(--color-border);
padding: 16px;
border-radius: var(--radius-lg);
background: rgba(255, 253, 249, 0.82);
box-shadow: var(--shadow-soft);
}
.imagecontainer {
@@ -438,10 +615,12 @@ export default {
}
.avatar {
border: 1px solid #ccc;
border-radius: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background-color: rgba(255,255,255,0.72);
background-repeat: no-repeat;
image-rendering: crisp-edges;
box-shadow: var(--shadow-soft);
}
.house-with-character {
@@ -453,8 +632,8 @@ export default {
.house {
position: absolute;
inset: 0;
border: 1px solid #ccc;
border-radius: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background-repeat: no-repeat;
image-rendering: crisp-edges;
z-index: 1;
@@ -470,16 +649,13 @@ export default {
z-index: 2;
}
h2 {
padding-top: 20px;
}
.heir-selection-container {
border: 2px solid #dc3545;
border-radius: 8px;
border: 1px solid rgba(177, 59, 53, 0.18);
border-radius: var(--radius-lg);
padding: 20px;
margin: 20px 0;
background-color: #fff3cd;
background-color: rgba(255, 243, 205, 0.92);
box-shadow: var(--shadow-soft);
}
.heir-selection-container h3 {
@@ -495,10 +671,10 @@ h2 {
}
.heir-card {
border: 1px solid #ccc;
border-radius: 4px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 15px;
background-color: white;
background-color: rgba(255,255,255,0.86);
display: flex;
justify-content: space-between;
align-items: center;
@@ -518,6 +694,20 @@ h2 {
font-size: 0.9em;
}
@media (max-width: 1100px) {
.falukant-routine-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.falukant-summary-grid,
.falukant-routine-grid,
.overviewcontainer {
grid-template-columns: 1fr;
}
}
.select-heir-button {
background-color: #28a745;
color: white;
@@ -535,6 +725,16 @@ h2 {
.loading, .no-heirs {
text-align: center;
padding: 20px;
color: #666;
color: var(--color-text-secondary);
}
@media (max-width: 960px) {
.overviewcontainer {
grid-template-columns: 1fr;
}
.imagecontainer {
flex-direction: column;
}
}
</style>

View File

@@ -1,9 +1,14 @@
<template>
<div class="home-logged-in">
<header class="dashboard-header">
<h1>Willkommen zurück!</h1>
<p class="dashboard-subtitle">Schön, dass du wieder da bist.</p>
<div class="dashboard-toolbar">
<section class="dashboard-hero surface-card">
<div class="dashboard-hero__copy">
<span class="dashboard-kicker">Dein Bereich</span>
<h1>Willkommen zurück!</h1>
<p class="dashboard-subtitle">
Dein persönlicher Einstieg in Community, Termine, Falukant und laufende Aktivitäten.
</p>
</div>
<div class="dashboard-toolbar surface-card">
<button
v-if="!editMode"
type="button"
@@ -42,7 +47,25 @@
</button>
</template>
</div>
</header>
</section>
<section class="dashboard-overview">
<article class="overview-card surface-card">
<span class="overview-card__label">Aktive Widgets</span>
<strong>{{ widgets.length }}</strong>
<p>Dein Dashboard ist modular aufgebaut und kann jederzeit umsortiert werden.</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">Verfügbare Module</span>
<strong>{{ widgetTypeOptions.length }}</strong>
<p>Du kannst Community-, Kalender-, News- und Falukant-Module kombinieren.</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">Bearbeitungsmodus</span>
<strong>{{ editMode ? 'Aktiv' : 'Aus' }}</strong>
<p>{{ editMode ? 'Widgets können gerade ergänzt und angepasst werden.' : 'Inhalte bleiben fokussiert und ruhig lesbar.' }}</p>
</article>
</section>
<div
v-if="loadError"
@@ -58,11 +81,20 @@
</div>
<div
v-else
ref="dashboardGridRef"
class="dashboard-grid"
@dragover.prevent
@drop.prevent="onAnyDrop($event)"
class="dashboard-shell"
>
<div class="dashboard-shell__header">
<div>
<h2>Deine Übersicht</h2>
<p>Widgets lassen sich verschieben und im Bearbeitungsmodus anpassen.</p>
</div>
</div>
<div
ref="dashboardGridRef"
class="dashboard-grid"
@dragover.prevent
@drop.prevent="onAnyDrop($event)"
>
<template v-for="(w, index) in widgets" :key="w.id">
<div
class="dashboard-grid-cell"
@@ -103,6 +135,7 @@
</div>
</div>
</template>
</div>
</div>
<div v-if="widgets.length === 0 && !loading" class="dashboard-empty">
@@ -306,98 +339,170 @@ export default {
<style scoped>
.home-logged-in {
max-width: 1200px;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 20px;
padding: 8px 0 24px;
}
.dashboard-header {
margin-bottom: 24px;
.dashboard-hero {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 20px;
padding: 26px;
margin-bottom: 18px;
background:
radial-gradient(circle at top right, rgba(248, 162, 43, 0.18), transparent 28%),
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(250, 243, 233, 0.98) 100%);
}
.dashboard-header h1 {
color: #333;
margin: 0 0 4px 0;
.dashboard-hero__copy {
max-width: 640px;
}
.dashboard-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboard-hero h1 {
margin: 0 0 8px;
}
.dashboard-subtitle {
color: #666;
margin: 0 0 16px 0;
color: var(--color-text-secondary);
margin: 0;
max-width: 58ch;
}
.dashboard-overview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.overview-card {
padding: 18px 20px;
}
.overview-card__label {
display: inline-block;
margin-bottom: 12px;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.overview-card strong {
display: block;
margin-bottom: 8px;
font-size: 1.9rem;
line-height: 1;
color: var(--color-text-primary);
}
.overview-card p {
margin: 0;
color: var(--color-text-secondary);
}
.dashboard-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
align-self: flex-start;
flex-wrap: wrap;
gap: 10px;
padding: 14px;
min-width: 300px;
background: rgba(255, 255, 255, 0.72);
}
.btn-edit,
.btn-done {
padding: 8px 14px;
border-radius: 4px;
border: 1px solid transparent;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
cursor: pointer;
font-size: 0.9rem;
min-height: 40px;
}
.btn-edit:hover,
.btn-done:hover {
background: var(--color-primary-orange-light);
color: var(--color-text-secondary);
border-color: var(--color-text-secondary);
color: #2b1f14;
}
.widget-add-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.btn-add-again {
padding: 8px 14px;
border-radius: 4px;
border: 1px solid var(--color-text-secondary);
background: #fff;
color: var(--color-text-primary);
font-size: 0.9rem;
cursor: pointer;
min-height: 40px;
background: rgba(255, 255, 255, 0.78);
border-color: var(--color-border-strong);
box-shadow: none;
}
.btn-add-again:hover {
background: var(--color-primary-orange-light);
border-color: var(--color-primary-orange);
background: rgba(255, 255, 255, 0.96);
}
.widget-type-select {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid var(--color-text-secondary);
background: #fff;
color: var(--color-text-primary);
font-size: 0.9rem;
min-width: 180px;
}
.dashboard-message {
padding: 16px;
border-radius: 8px;
padding: 16px 18px;
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.dashboard-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
background: rgba(177, 59, 53, 0.12);
color: #7a241f;
border: 1px solid rgba(177, 59, 53, 0.18);
}
.dashboard-shell {
padding: 20px;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
background:
linear-gradient(180deg, rgba(255, 252, 247, 0.94) 0%, rgba(248, 241, 231, 0.96) 100%);
box-shadow: var(--shadow-soft);
}
.dashboard-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.dashboard-shell__header h2 {
margin: 0 0 4px;
font-size: 1.4rem;
}
.dashboard-shell__header p {
margin: 0;
color: var(--color-text-secondary);
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-auto-rows: 200px;
gap: 20px;
grid-auto-rows: 220px;
gap: 18px;
}
.dashboard-grid-cell {
@@ -415,9 +520,9 @@ export default {
}
.dashboard-grid-cell.drop-target {
outline: 2px dashed #0d6efd;
outline: 2px dashed rgba(248, 162, 43, 0.82);
outline-offset: 4px;
border-radius: 8px;
border-radius: var(--radius-md);
}
.dashboard-grid-cell.drag-source {
@@ -426,13 +531,14 @@ export default {
.dashboard-widget-edit {
min-height: 200px;
padding: 12px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 16px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: var(--shadow-soft);
}
.widget-edit-fields {
@@ -441,40 +547,58 @@ export default {
gap: 8px;
}
.widget-edit-input {
padding: 8px 10px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9rem;
}
.btn-remove {
align-self: flex-start;
padding: 6px 12px;
border: 1px solid transparent;
background: var(--color-primary-orange);
color: var(--color-text-on-orange);
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
min-height: 36px;
background: rgba(177, 59, 53, 0.12);
color: #7a241f;
border-color: rgba(177, 59, 53, 0.18);
box-shadow: none;
}
.btn-remove:hover {
background: var(--color-primary-orange-light);
color: var(--color-text-secondary);
border-color: var(--color-text-secondary);
background: rgba(177, 59, 53, 0.18);
}
.dashboard-empty {
padding: 32px;
text-align: center;
color: #666;
background: #f8f9fa;
border-radius: 8px;
border: 1px dashed #dee2e6;
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.72);
border-radius: var(--radius-lg);
border: 1px dashed var(--color-border-strong);
box-shadow: var(--shadow-soft);
}
.actions {
margin-top: 30px;
}
@media (max-width: 960px) {
.home-logged-in {
padding-bottom: 18px;
}
.dashboard-hero {
flex-direction: column;
padding: 20px;
}
.dashboard-toolbar {
width: 100%;
min-width: 0;
}
.dashboard-overview {
grid-template-columns: 1fr;
}
.dashboard-shell {
padding: 16px;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -8,44 +8,56 @@
<Character3D gender="male" />
</div>
<div class="actions">
<div>
<h2>{{ $t('home.nologin.welcome') }}</h2>
<p>{{ $t('home.nologin.description') }}</p>
<section class="actions-panel actions-panel--story surface-card">
<div class="panel-intro">
<span class="panel-kicker">Dein Einstieg</span>
<h2>{{ $t('home.nologin.welcome') }}</h2>
<p>{{ $t('home.nologin.description') }}</p>
</div>
<p>
YourPart ist eine wachsende OnlinePlattform, die CommunityFunktionen, EchtzeitChat, Foren,
ein soziales Netzwerk mit Bildergalerie sowie das Aufbauspiel <em>Falukant</em> vereint.
Aktuell befindet sich die Seite in der BetaPhase wir erweitern Funktionen, Inhalte und
Stabilität
kontinuierlich.
</p>
<div class="story-highlight">
<p>
YourPart verbindet Community, Echtzeit-Chat, Foren, Bildergalerie und das Aufbauspiel
<em>Falukant</em> in einer Plattform. Der Fokus liegt auf Austausch, spielerischer Tiefe und
einer wachsenden Produktwelt.
</p>
</div>
<h3>{{ $t('home.nologin.expected.title') }}</h3>
<ul>
<div class="story-block">
<h3>{{ $t('home.nologin.expected.title') }}</h3>
<ul class="feature-list">
<li v-html="$t('home.nologin.expected.items.chat')"></li>
<li v-html="$t('home.nologin.expected.items.social')"></li>
<li v-html="$t('home.nologin.expected.items.forum')"></li>
<li v-html="$t('home.nologin.expected.items.falukant')"></li>
<li v-html="$t('home.nologin.expected.items.minigames')"></li>
<li v-html="$t('home.nologin.expected.items.multilingual')"></li>
</ul>
</ul>
</div>
<h3>{{ $t('home.nologin.falukantShort.title') }}</h3>
<p>{{ $t('home.nologin.falukantShort.text') }}</p>
<div class="story-columns">
<article>
<h3>{{ $t('home.nologin.falukantShort.title') }}</h3>
<p>{{ $t('home.nologin.falukantShort.text') }}</p>
</article>
<article>
<h3>{{ $t('home.nologin.privacyBeta.title') }}</h3>
<p>{{ $t('home.nologin.privacyBeta.text') }}</p>
</article>
</div>
<h3>{{ $t('home.nologin.privacyBeta.title') }}</h3>
<p>{{ $t('home.nologin.privacyBeta.text') }}</p>
<h3>{{ $t('home.nologin.getStarted.title') }}</h3>
<p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p>
</div>
<div>
<div>
<div>
<div class="story-cta">
<h3>{{ $t('home.nologin.getStarted.title') }}</h3>
<p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p>
</div>
</section>
<section class="actions-panel actions-panel--access surface-card">
<div class="login-panel">
<span class="panel-kicker">Direkt starten</span>
<h2>{{ $t('home.nologin.login.submit') }}</h2>
<div class="login-fields">
<input v-model="username" size="20" type="text" :placeholder="$t('home.nologin.login.name')"
:title="$t('home.nologin.login.namedescription')" @keydown.enter="focusPassword">
</div>
<div>
<input v-model="password" size="20" type="password"
:placeholder="$t('home.nologin.login.password')"
:title="$t('home.nologin.login.passworddescription')" @keydown.enter="doLogin"
@@ -57,20 +69,26 @@
<span>{{ $t('home.nologin.login.stayLoggedIn') }}</span>
</label>
</div>
<button type="button" class="primary-action" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
</div>
<div>
<button type="button" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
<div class="access-split">
<article class="access-card">
<h3>{{ $t('home.nologin.randomchat') }}</h3>
<p>Ohne lange Vorbereitung direkt in spontane Begegnungen und offene Gespraeche starten.</p>
<button type="button" class="secondary-action" @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
</article>
<article class="access-card">
<h3>Konto und Zugang</h3>
<p>Neu hier oder Passwort vergessen? Von hier aus gelangst du direkt in Registrierung und Wiederherstellung.</p>
<div class="access-links">
<span @click="openPasswordResetDialog" class="link">{{
$t('home.nologin.login.lostpassword') }}</span>
<span @click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
</div>
</article>
</div>
<div>
<h2>{{ $t('home.nologin.randomchat') }}</h2>
<button @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
</div>
<div>
<span @click="openPasswordResetDialog" class="link">{{
$t('home.nologin.login.lostpassword') }}</span> | <span id="o1p5iry1"
@click="openRegisterDialog" class="link">{{ $t('home.nologin.login.register') }}</span>
</div>
</div>
</section>
</div>
<div class="mascot">
<Character3D gender="female" />
@@ -138,19 +156,22 @@ export default {
<style scoped>
.beta-banner {
background: #fff3cd;
border: 1px solid #ffeeba;
color: #856404;
width: min(100%, var(--content-max-width));
background: linear-gradient(180deg, #fff2cf 0%, #fde7b2 100%);
border: 1px solid rgba(201, 130, 31, 0.24);
color: #8a5a12;
padding: 10px 14px;
margin: 0 0 12px 0;
margin: 0 0 14px 0;
text-align: center;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.home-structure {
display: flex;
align-items: stretch;
justify-content: center;
gap: 2em;
gap: 1.4rem;
width: 100%;
height: 100%;
flex: 1;
@@ -169,7 +190,7 @@ export default {
align-items: stretch;
background: linear-gradient(180deg, #fff5e8 0%, #fce7ca 100%);
border: 1px solid rgba(248, 162, 43, 0.16);
border-radius: 20px;
border-radius: var(--radius-lg);
box-shadow: 0 10px 24px rgba(93, 64, 55, 0.08);
overflow: hidden;
align-self: center;
@@ -181,26 +202,124 @@ export default {
.actions {
display: flex;
flex-direction: column;
gap: 2em;
gap: 1rem;
flex: 1 1 auto;
min-height: 0;
}
.actions>div {
.actions-panel {
flex: 1;
min-height: 0;
background-color: #FFF4F0;
align-items: center;
justify-content: flex-start;
display: flex;
background:
linear-gradient(180deg, rgba(255, 251, 246, 0.96) 0%, rgba(248, 240, 231, 0.96) 100%);
color: #5D4037;
display: flex;
flex-direction: column;
overflow: auto;
padding: 0.5rem;
padding: 1.2rem 1.25rem;
text-align: left;
}
.actions>div>h2 {
color: var(--color-primary-orange);
.actions-panel h2,
.actions-panel h3 {
width: 100%;
}
.panel-kicker {
display: inline-block;
margin-bottom: 0.7rem;
padding: 0.3rem 0.65rem;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
color: #8a5411;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.panel-intro,
.story-highlight,
.story-block,
.story-columns,
.story-cta,
.login-panel,
.access-split {
width: 100%;
}
.story-highlight {
padding: 1rem 1.1rem;
margin: 0.8rem 0 1rem;
border-radius: var(--radius-lg);
background: rgba(248, 162, 43, 0.08);
border: 1px solid rgba(248, 162, 43, 0.12);
}
.story-block {
margin-bottom: 1rem;
}
.feature-list {
padding-left: 1.1rem;
}
.feature-list li + li {
margin-top: 0.55rem;
}
.story-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
margin-bottom: 1rem;
}
.story-columns article,
.story-cta,
.access-card {
padding: 1rem 1.05rem;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.68);
border: 1px solid var(--color-border);
}
.login-panel {
padding: 1rem 1.05rem;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.login-fields {
display: grid;
gap: 0.8rem;
}
.primary-action,
.secondary-action {
align-self: flex-start;
}
.primary-action {
margin-top: 0.8rem;
}
.access-split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.access-card p {
margin-bottom: 0.9rem;
}
.access-links {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
}
.stay-logged-in-row {
@@ -299,8 +418,13 @@ export default {
min-height: auto;
}
.actions>div {
.actions-panel {
min-height: 260px;
}
.story-columns,
.access-split {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,11 +1,36 @@
<template>
<div class="contenthidden">
<div class="contentscroll">
<div class="contentscroll match3-view">
<!-- Spiel-Titel -->
<div class="game-title">
<section class="game-title surface-card">
<span class="game-title__eyebrow">Minispiele</span>
<h1>{{ $t('minigames.match3.title') }}</h1>
<p>{{ $t('minigames.match3.campaignDescription') }}</p>
</div>
</section>
<section class="play-focus surface-card">
<div class="play-focus__main">
<span class="play-focus__eyebrow">Naechster Schritt</span>
<h2>{{ playFocusTitle }}</h2>
<p>{{ playFocusDescription }}</p>
</div>
<div class="play-focus__stats">
<span class="play-focus__pill">Level {{ currentLevel }}</span>
<span class="play-focus__pill">{{ completedObjectivesCount }}/{{ totalObjectivesCount || 0 }} Ziele</span>
<span class="play-focus__pill">{{ safeMovesLeft }} Zuege uebrig</span>
</div>
<div class="play-focus__actions">
<button class="btn btn-primary" @click="isPaused ? resumeGame() : pauseGame()">
{{ isPaused ? $t('minigames.match3.resume') : $t('minigames.match3.pause') }}
</button>
<button class="btn btn-secondary" @click="toggleLevelDescription">
{{ levelDescriptionExpanded ? 'Ziele einklappen' : 'Ziele anzeigen' }}
</button>
<button class="btn btn-secondary" @click="restartLevel">
{{ $t('minigames.match3.restartLevel') }}
</button>
</div>
</section>
<!-- Kampagnen-Status -->
<div class="game-layout">
@@ -45,13 +70,13 @@
<div class="game-content">
<!-- Verbleibende Züge -->
<div class="moves-left-display">
<div class="moves-left-display surface-card">
<span class="moves-left-label">{{ $t('minigames.match3.movesLeft') }}:</span>
<span class="moves-left-value">{{ safeMovesLeft }}</span>
</div>
<!-- Level-Info -->
<div class="level-info-card" v-if="currentLevelData">
<div class="level-info-card surface-card" v-if="currentLevelData">
<div class="level-header">
<div class="level-header-content">
<h3 class="level-title">
@@ -6010,6 +6035,42 @@ export default {
},
safeMovesLeft() {
return this.movesLeft || 0;
},
totalObjectivesCount() {
return this.currentLevelData?.objectives?.length || 0;
},
completedObjectivesCount() {
if (!this.currentLevelData?.objectives?.length) {
return 0;
}
return this.currentLevelData.objectives.filter((objective) => objective.completed).length;
},
nextPendingObjective() {
return this.currentLevelData?.objectives?.find((objective) => !objective.completed) || null;
},
playFocusTitle() {
if (this.isPaused) {
return 'Spiel ist pausiert';
}
if (!this.currentLevelData) {
return 'Level wird vorbereitet';
}
if (this.nextPendingObjective) {
return this.nextPendingObjective.description || 'Aktuelles Ziel abschliessen';
}
return 'Level sauber zu Ende spielen';
},
playFocusDescription() {
if (this.isPaused) {
return 'Setze das Level fort oder starte es kontrolliert neu, ohne den aktuellen Kontext zu verlieren.';
}
if (!this.currentLevelData) {
return 'Sobald das Level geladen ist, erscheinen hier das naechste Ziel und die passende Hauptaktion.';
}
if (this.nextPendingObjective) {
return `Konzentriere dich zuerst auf dieses Ziel. Bereits erledigt: ${this.completedObjectivesCount} von ${this.totalObjectivesCount}.`;
}
return 'Alle sichtbaren Ziele sind erledigt. Jetzt zaehlt nur noch der saubere Abschluss des Levels.';
}
}
}
@@ -6019,26 +6080,94 @@ export default {
/* Minimalistischer Style - nur für Match3Game */
/* Verwendet globale Scroll-Klassen: .contenthidden und .contentscroll */
.match3-view {
padding-bottom: 24px;
}
.game-title {
text-align: center;
margin-bottom: 30px;
padding-top: 20px;
margin: 16px auto 30px;
max-width: 980px;
padding: 28px;
background: linear-gradient(135deg, rgba(255, 247, 233, 0.98), rgba(245, 237, 225, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.game-title__eyebrow {
display: inline-flex;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-primary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.game-title h1 {
margin: 0 0 10px 0;
font-size: 2rem;
font-weight: 600;
color: #333;
color: var(--color-text-primary);
}
.game-title p {
margin: 0;
font-size: 1.1rem;
color: #666;
color: var(--color-text-secondary);
line-height: 1.5;
}
.play-focus {
display: grid;
gap: 14px;
max-width: 980px;
margin: 0 auto 20px;
padding: 20px 24px;
}
.play-focus__eyebrow {
display: inline-flex;
margin-bottom: 4px;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.play-focus h2 {
margin: 0 0 8px;
font-size: 1.35rem;
}
.play-focus p {
margin: 0;
color: var(--color-text-secondary);
}
.play-focus__stats,
.play-focus__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.play-focus__pill {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
color: #8a5411;
font-weight: 700;
}
/* Spiel-Layout */
.game-layout {
display: flex;
@@ -6062,12 +6191,12 @@ export default {
/* Verbleibende Züge Anzeige */
.moves-left-display {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 5px;
background: rgba(255, 251, 246, 0.96);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px 14px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: var(--shadow-soft);
text-align: center;
display: flex;
align-items: center;
@@ -6078,7 +6207,7 @@ export default {
.moves-left-label {
font-size: 16px;
font-weight: 500;
color: #333;
color: var(--color-text-secondary);
}
.moves-left-value {
@@ -6089,17 +6218,17 @@ export default {
/* Statistik-Bereich */
.stats-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 5px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
background: rgba(255, 251, 246, 0.96);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 8px;
box-shadow: var(--shadow-soft);
}
.stats-header {
cursor: pointer;
padding: 8px;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--color-border);
margin-bottom: 10px;
user-select: none;
transition: background-color 0.2s ease;
@@ -6107,7 +6236,7 @@ export default {
}
.stats-header:hover {
background-color: #f8f9fa;
background-color: rgba(248, 162, 43, 0.08);
}
.stats-header-content {
@@ -6121,7 +6250,7 @@ export default {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
color: var(--color-text-primary);
pointer-events: none;
}
@@ -6174,7 +6303,7 @@ export default {
.stat-label {
font-size: 12px;
color: #666;
color: var(--color-text-secondary);
}
/* Statistik-Werte Farben */
@@ -6185,12 +6314,12 @@ export default {
/* Level-Info */
.level-info-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 5px;
background: rgba(255, 251, 246, 0.96);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
box-shadow: var(--shadow-soft);
width: 100%;
max-width: 600px;
}
@@ -6198,7 +6327,7 @@ export default {
.level-header {
cursor: pointer;
padding: 8px;
border-bottom: 1px solid #eee;
border-bottom: 1px solid var(--color-border);
margin-bottom: 10px;
user-select: none;
transition: background-color 0.2s ease;
@@ -6206,7 +6335,7 @@ export default {
}
.level-header:hover {
background-color: #f8f9fa;
background-color: rgba(248, 162, 43, 0.08);
}
.level-header-content {
@@ -6220,7 +6349,7 @@ export default {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
color: var(--color-text-primary);
flex-grow: 1;
text-align: left;
pointer-events: none;
@@ -6233,7 +6362,7 @@ export default {
.level-info-card p {
margin: 0 0 15px 0;
text-align: left;
color: #666;
color: var(--color-text-secondary);
line-height: 1.6;
}
@@ -6248,8 +6377,8 @@ export default {
align-items: center;
margin-bottom: 8px;
padding: 8px;
background-color: #f8f9fa;
border-radius: 4px;
background-color: rgba(255, 255, 255, 0.64);
border-radius: var(--radius-sm);
}
.objective-icon {
@@ -6265,7 +6394,7 @@ export default {
.objective-progress {
margin-left: auto;
font-size: 12px;
color: #6c757d;
color: var(--color-text-muted);
font-weight: 500;
}
@@ -6273,10 +6402,10 @@ export default {
.game-board-container {
display: inline-block;
padding: 20px;
background: white;
border-radius: 8px;
border: 1px solid #ddd;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
background: rgba(255, 251, 246, 0.96);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-soft);
margin-bottom: 20px;
position: relative; /* Für absolute Positionierung der Animationen */
}
@@ -6461,6 +6590,17 @@ export default {
margin-bottom: 20px;
}
@media (max-width: 760px) {
.game-title {
margin-top: 12px;
padding: 22px 18px;
}
.play-focus {
padding: 18px;
}
}
.btn {
display: inline-block;
padding: 10px 20px;
@@ -6875,4 +7015,3 @@ export default {
}
}
</style>

View File

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

View File

@@ -37,7 +37,7 @@
.hero {
padding: 32px;
border-radius: 20px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, #f7e0bb 0%, #f6c27d 45%, #e8924d 100%);
box-shadow: 0 20px 60px rgba(106, 56, 20, 0.18);
}
@@ -83,7 +83,7 @@
.grid article {
padding: 24px;
border-radius: 18px;
border-radius: var(--radius-lg);
background: #fff7ef;
border: 1px solid rgba(64, 38, 26, 0.08);
}

View File

@@ -33,7 +33,7 @@
.hero {
padding: 32px;
border-radius: 20px;
border-radius: var(--radius-lg);
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
linear-gradient(135deg, #d4f0e6 0%, #7dd0be 40%, #2e8b83 100%);
@@ -81,7 +81,7 @@
.cards article {
padding: 24px;
border-radius: 18px;
border-radius: var(--radius-lg);
background: #effaf6;
border: 1px solid rgba(23, 50, 58, 0.08);
}

View File

@@ -37,7 +37,7 @@
.hero {
padding: 32px;
border-radius: 20px;
border-radius: var(--radius-lg);
background:
radial-gradient(circle at right top, rgba(255, 255, 255, 0.78), transparent 30%),
linear-gradient(135deg, #eef6c8 0%, #bddd74 45%, #6b9d34 100%);
@@ -85,7 +85,7 @@
.features article {
padding: 24px;
border-radius: 18px;
border-radius: var(--radius-lg);
background: #f7fbe9;
border: 1px solid rgba(31, 47, 29, 0.08);
}

View File

@@ -1,45 +1,66 @@
<template>
<div>
<h2>{{ $t("settings.account.title") }}</h2>
<div>
<label><span>{{ $t("settings.account.username") }} </span><input type="text" v-model="username"
:placeholder="$t('settings.account.username')" /></label>
</div>
<div>
<label><span>{{ $t("settings.account.email") }} </span><input type="text" v-model="email"
:placeholder="$t('settings.account.email')" /></label>
</div>
<div>
<label><span>{{ $t("settings.account.newpassword") }} </span><input type="password" v-model="newpassword"
:placeholder="$t('settings.account.newpassword')" autocomplete="new-password" /></label>
</div>
<div>
<label><span>{{ $t("settings.account.newpasswordretype") }} </span><input type="password"
v-model="newpasswordretype" :placeholder="$t('settings.account.newpasswordretype')" autocomplete="new-password" /></label>
</div>
<div>
<label><span>{{ $t("settings.account.oldpassword") }} </span><input type="password"
v-model="oldpassword" :placeholder="$t('settings.account.oldpassword')" autocomplete="current-password" /></label>
</div>
<div>
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
</div>
<div>
<label><input type="checkbox" v-model="showInSearch" /> {{ $t("settings.account.showinsearch") }}</label>
</div>
<div class="account-settings">
<section class="account-settings__hero surface-card">
<span class="account-settings__eyebrow">Einstellungen</span>
<h2>{{ $t("settings.account.title") }}</h2>
<p>Benutzername, E-Mail, Passwort und Sichtbarkeit an einer Stelle pflegen.</p>
</section>
<section class="account-settings__panel surface-card">
<div class="account-settings__grid">
<label class="account-settings__field">
<span>{{ $t("settings.account.username") }}</span>
<input type="text" v-model="username" :placeholder="$t('settings.account.username')" />
</label>
<label class="account-settings__field">
<span>{{ $t("settings.account.email") }}</span>
<input type="text" v-model="email" :placeholder="$t('settings.account.email')" />
</label>
<label class="account-settings__field">
<span>{{ $t("settings.account.newpassword") }}</span>
<input type="password" v-model="newpassword" :placeholder="$t('settings.account.newpassword')"
autocomplete="new-password" :class="{ 'field-error': newpassword && !isNewPasswordValid }" />
<span v-if="newpassword && !isNewPasswordValid" class="form-error">Das neue Passwort sollte mindestens 8 Zeichen haben.</span>
</label>
<label class="account-settings__field">
<span>{{ $t("settings.account.newpasswordretype") }}</span>
<input type="password" v-model="newpasswordretype"
:placeholder="$t('settings.account.newpasswordretype')" autocomplete="new-password"
:class="{ 'field-error': newpasswordretype && !passwordsMatch }" />
<span v-if="newpasswordretype && !passwordsMatch" class="form-error">Die Passwoerter stimmen nicht ueberein.</span>
</label>
<label class="account-settings__field account-settings__field--full">
<span>{{ $t("settings.account.oldpassword") }}</span>
<input type="password" v-model="oldpassword" :placeholder="$t('settings.account.oldpassword')"
autocomplete="current-password" :class="{ 'field-error': requiresOldPassword && !oldpassword.trim() }" />
<span v-if="requiresOldPassword && !oldpassword.trim()" class="form-error">Zum Passwortwechsel wird das aktuelle Passwort benoetigt.</span>
</label>
</div>
<label class="account-settings__toggle">
<input type="checkbox" v-model="showInSearch" />
<span>{{ $t("settings.account.showinsearch") }}</span>
</label>
<div class="account-settings__actions">
<button @click="changeAccount">{{ $t("settings.account.changeaction") }}</button>
</div>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js';
export default {
name: "AccountSettingsView",
components: {},
computed: {
...mapGetters(['user']),
},
data() {
return {
username: "",
@@ -50,6 +71,18 @@ export default {
oldpassword: "",
};
},
computed: {
...mapGetters(['user']),
requiresOldPassword() {
return this.newpassword.trim().length > 0;
},
isNewPasswordValid() {
return this.newpassword.length === 0 || this.newpassword.length >= 8;
},
passwordsMatch() {
return this.newpassword === this.newpasswordretype;
}
},
methods: {
async changeAccount() {
try {
@@ -57,15 +90,19 @@ export default {
const hasNewPassword = this.newpassword && this.newpassword.trim() !== '';
if (hasNewPassword) {
if (!this.isNewPasswordValid) {
showError(this, 'Das neue Passwort ist noch zu kurz.');
return;
}
// Validiere Passwort-Wiederholung nur wenn ein neues Passwort eingegeben wurde
if (this.newpassword !== this.newpasswordretype) {
alert('Die Passwörter stimmen nicht überein.');
if (!this.passwordsMatch) {
showError(this, 'Die Passwoerter stimmen nicht ueberein.');
return;
}
// Prüfe ob das alte Passwort eingegeben wurde
if (!this.oldpassword || this.oldpassword.trim() === '') {
alert('Bitte geben Sie Ihr aktuelles Passwort ein, um das Passwort zu ändern.');
showError(this, 'Bitte geben Sie Ihr aktuelles Passwort ein, um das Passwort zu aendern.');
return;
}
}
@@ -89,7 +126,7 @@ export default {
// API-Aufruf zum Speichern der Account-Einstellungen
await apiClient.post('/api/settings/set-account', accountData);
alert('Account-Einstellungen erfolgreich gespeichert!');
showSuccess(this, 'Account-Einstellungen erfolgreich gespeichert.');
// Leere die Passwort-Felder nach erfolgreichem Speichern
this.newpassword = '';
@@ -98,17 +135,12 @@ export default {
} catch (error) {
console.error('Fehler beim Speichern der Account-Einstellungen:', error);
if (error.response && error.response.data && error.response.data.error) {
alert('Fehler: ' + error.response.data.error);
} else {
alert('Ein Fehler ist aufgetreten beim Speichern der Account-Einstellungen.');
}
showApiError(this, error, 'Ein Fehler ist beim Speichern der Account-Einstellungen aufgetreten.');
}
}
},
async mounted() {
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
console.log(response.data);
this.username = response.data.username;
this.showInSearch = response.data.showinsearch;
this.email = response.data.email;
@@ -117,18 +149,101 @@ export default {
this.newpassword = '';
this.newpasswordretype = '';
this.oldpassword = '';
console.log(this.showInSearch);
},
};
</script>
<style lang="scss" scoped>
label {
white-space: nowrap;
.account-settings {
display: grid;
gap: 18px;
max-width: 960px;
}
label > span {
width: 15em;
display: inline-block;
.account-settings__hero,
.account-settings__panel {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
</style>
.account-settings__hero {
padding: 26px 28px;
}
.account-settings__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-primary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.account-settings__hero p {
margin: 0;
color: var(--color-text-secondary);
}
.account-settings__panel {
padding: 24px;
}
.account-settings__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.account-settings__field {
display: grid;
gap: 8px;
}
.account-settings__field span {
font-weight: 600;
color: var(--color-text-secondary);
}
.account-settings__field--full {
grid-column: 1 / -1;
}
.account-settings__toggle {
display: inline-flex;
align-items: center;
gap: 10px;
margin-top: 18px;
color: var(--color-text-secondary);
}
.account-settings__actions {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 760px) {
.account-settings__grid {
grid-template-columns: 1fr;
}
.account-settings__hero,
.account-settings__panel {
padding: 20px;
}
.account-settings__actions {
justify-content: stretch;
}
.account-settings__actions button {
width: 100%;
}
}
</style>

View File

@@ -1,38 +1,46 @@
<template>
<h2>{{ $t('socialnetwork.diary.title') }}</h2>
<div class="new-entry-section">
<h3>{{ isEditing ? $t('socialnetwork.diary.editEntry') : $t('socialnetwork.diary.newEntry') }}</h3>
<textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea>
<div class="form-actions">
<button @click="saveEntry">{{ isEditing ? $t('socialnetwork.diary.update') : $t('socialnetwork.diary.save')
}}</button>
<button v-if="isEditing" @click="cancelEdit">{{ $t('socialnetwork.diary.cancel') }}</button>
</div>
</div>
<div v-if="diaryEntries.length === 0">{{ $t('socialnetwork.diary.noEntries') }}</div>
<div v-else class="diary-entries">
<div v-for="entry in diaryEntries" :key="entry.id" class="diary-entry">
<p v-html="sanitizedText(entry)"></p>
<div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
<span class="entry-actions">
<span @click="editEntry(entry)" class="button" :title="$t('socialnetwork.diary.edit')"></span>
<span @click="deleteEntry(entry.id)" class="button" :title="$t('socialnetwork.diary.delete')"></span>
</span>
<div class="diary-view">
<section class="diary-hero surface-card">
<div>
<span class="diary-kicker">Persoenliche Eintraege</span>
<h2>{{ $t('socialnetwork.diary.title') }}</h2>
<p>Gedanken, Notizen und kurze Updates in einer ruhigen, persoenlichen Ansicht.</p>
</div>
</div>
</div>
</section>
<div class=" pagination">
<button @click="loadDiaryEntries(currentPage - 1)" v-if="currentPage !== 1">{{
$t('socialnetwork.diary.prevPage') }}</button>
<span>{{ $t('socialnetwork.diary.page') }} {{ currentPage }} / {{ totalPages }}</span>
<button @click="loadDiaryEntries(currentPage + 1)" v-if="currentPage < totalPages">{{
$t('socialnetwork.diary.nextPage') }}</button>
<section class="new-entry-section surface-card">
<h3>{{ isEditing ? $t('socialnetwork.diary.editEntry') : $t('socialnetwork.diary.newEntry') }}</h3>
<textarea v-model="newEntryText" placeholder="Write your diary entry..."></textarea>
<div class="form-actions">
<button @click="saveEntry">{{ isEditing ? $t('socialnetwork.diary.update') : $t('socialnetwork.diary.save')
}}</button>
<button v-if="isEditing" @click="cancelEdit">{{ $t('socialnetwork.diary.cancel') }}</button>
</div>
</section>
<div v-if="diaryEntries.length === 0" class="diary-empty surface-card">{{ $t('socialnetwork.diary.noEntries') }}</div>
<section v-else class="diary-entries">
<article v-for="entry in diaryEntries" :key="entry.id" class="diary-entry surface-card">
<p v-html="sanitizedText(entry)"></p>
<div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
<span class="entry-actions">
<span @click="editEntry(entry)" class="button" :title="$t('socialnetwork.diary.edit')"></span>
<span @click="deleteEntry(entry.id)" class="button" :title="$t('socialnetwork.diary.delete')"></span>
</span>
</div>
</article>
</section>
<div class="pagination">
<button @click="loadDiaryEntries(currentPage - 1)" v-if="currentPage !== 1">{{
$t('socialnetwork.diary.prevPage') }}</button>
<span>{{ $t('socialnetwork.diary.page') }} {{ currentPage }} / {{ totalPages }}</span>
<button @click="loadDiaryEntries(currentPage + 1)" v-if="currentPage < totalPages">{{
$t('socialnetwork.diary.nextPage') }}</button>
</div>
<ChooseDialog ref="chooseDialog" />
</div>
<ChooseDialog ref="chooseDialog" />
</template>
<script>
@@ -137,13 +145,38 @@ export default {
</script>
<style scoped>
.diary-view {
max-width: 820px;
margin: 0 auto;
padding-bottom: 24px;
}
.diary-hero,
.new-entry-section {
margin-bottom: 20px;
margin-bottom: 16px;
padding: 22px;
}
.diary-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.diary-hero p {
margin: 0;
color: var(--color-text-secondary);
}
textarea {
width: 100%;
height: 100px;
height: 140px;
margin-bottom: 10px;
}
@@ -152,13 +185,12 @@ textarea {
}
.diary-entry {
border-bottom: 1px solid #ccc;
margin-bottom: 1em;
padding-bottom: 1em;
padding: 18px 20px;
}
.entry-info {
color: gray;
color: var(--color-text-muted);
display: flex;
flex-direction: row;
}
@@ -176,13 +208,23 @@ textarea {
.pagination {
margin-top: 1em;
background-color: #7BBE55;
color: #fff;
color: var(--color-text-secondary);
padding: 0.5em 0;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
}
.diary-entries {
width: 400px;
display: grid;
gap: 12px;
}
.diary-empty {
padding: 22px;
text-align: center;
color: var(--color-text-secondary);
}
</style>

View File

@@ -1,22 +1,32 @@
<template>
<h2 class="link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
<h3 v-if="forumTopic">{{ forumTopic }}</h3>
<ul class="messages">
<li v-for="message in messages" :key="message.id">
<div v-html="sanitizedMessage(message)"></div>
<div class="footer">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
{{ message.lastMessageUser.username }}
</span>
<span>{{ new Date(message.createdAt).toLocaleString() }}</span>
<div class="forum-topic-view">
<section class="forum-topic-hero surface-card">
<div>
<div class="forum-topic-back link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</div>
<h2 v-if="forumTopic">{{ forumTopic }}</h2>
<p>Diskussionen, Antworten und neue Beitraege in einer fokussierten Leseflaeche.</p>
</div>
</li>
</ul>
</section>
<div class="editor-container">
<EditorContent v-if="editor" :editor="editor" class="editor" />
<section class="forum-topic-messages">
<ul class="messages">
<li v-for="message in messages" :key="message.id" class="surface-card">
<div v-html="sanitizedMessage(message)"></div>
<div class="footer">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
{{ message.lastMessageUser.username }}
</span>
<span>{{ new Date(message.createdAt).toLocaleString() }}</span>
</div>
</li>
</ul>
</section>
<div class="editor-container surface-card">
<EditorContent v-if="editor" :editor="editor" class="editor" />
</div>
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
</div>
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
</template>
<script>
@@ -98,6 +108,27 @@ export default {
}
</script>
<style lang="scss" scoped>
.forum-topic-view {
max-width: 860px;
margin: 0 auto;
padding-bottom: 24px;
}
.forum-topic-hero {
padding: 24px 26px;
margin-bottom: 16px;
}
.forum-topic-back {
margin-bottom: 10px;
font-weight: 700;
}
.forum-topic-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.messages {
list-style-type: none;
padding: 0;
@@ -105,14 +136,13 @@ export default {
}
.messages > li {
border: 1px solid #7BBE55;
margin-bottom: 0.25em;
padding: 0.5em;
margin-bottom: 0.75em;
padding: 1rem 1.1rem;
}
.messages > li > .footer {
color: #F9A22C;
font-size: 0.7em;
color: var(--color-text-muted);
font-size: 0.8em;
margin-top: 0.5em;
display: flex;
}
@@ -127,10 +157,10 @@ export default {
.editor-container {
margin-top: 1rem;
border: 1px solid #ccc;
padding: 0;
min-height: 260px;
background-color: white;
overflow: hidden;
}
.editor {
@@ -141,7 +171,7 @@ export default {
.editor :deep(.ProseMirror) {
min-height: 260px;
outline: none;
padding: 10px;
padding: 14px;
box-sizing: border-box;
width: 100%;
}

View File

@@ -1,42 +1,54 @@
<template>
<h2>{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
<div class="creationtoggler">
<button @click="createNewTopic">
{{ $t(!inCreation
? 'socialnetwork.forum.showNewTopic'
: 'socialnetwork.forum.hideNewTopic') }}
</button>
</div>
<div class="forum-view">
<section class="forum-hero surface-card">
<div>
<span class="forum-kicker">Community-Forum</span>
<h2>{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
<p>Themen, Diskussionen und neue Beitraege an einem strukturierten Ort.</p>
</div>
<div class="creationtoggler">
<button @click="createNewTopic">
{{ $t(!inCreation
? 'socialnetwork.forum.showNewTopic'
: 'socialnetwork.forum.hideNewTopic') }}
</button>
</div>
</section>
<div v-if="inCreation">
<div>
<section v-if="inCreation" class="forum-creation surface-card">
<label class="newtitle">
{{ $t('socialnetwork.forum.topic') }}
<span>{{ $t('socialnetwork.forum.topic') }}</span>
<input type="text" v-model="newTitle" />
</label>
</div>
<div class="editor-container">
<EditorContent v-if="editor" :editor="editor" class="editor" />
</div>
<button @click="saveNewTopic">
{{ $t('socialnetwork.forum.createNewTopic') }}
</button>
</div>
<div class="editor-container">
<EditorContent v-if="editor" :editor="editor" class="editor" />
</div>
<button @click="saveNewTopic">
{{ $t('socialnetwork.forum.createNewTopic') }}
</button>
</section>
<div v-else-if="titles.length > 0">
<!-- hier kommt deine bestehende TABLE + PAGINATION hin -->
<table>
<!-- Kopfzeile, Spalten etc. -->
</table>
<div class="pagination">
<button @click="goToPage(page-1)" :disabled="page<=1"></button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="goToPage(page+1)" :disabled="page>=totalPages"></button>
</div>
</div>
<section v-else-if="titles.length > 0" class="forum-topics surface-card">
<ul class="topic-list">
<li v-for="topic in titles" :key="topic.id" class="topic-card">
<button type="button" class="topic-card__main" @click="openTopic(topic.id)">
<strong>{{ topic.title }}</strong>
<span class="topic-card__meta">
{{ topic.user?.username || topic.owner?.username || 'Community' }}
</span>
</button>
</li>
</ul>
<div class="pagination">
<button @click="goToPage(page-1)" :disabled="page<=1"></button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="goToPage(page+1)" :disabled="page>=totalPages"></button>
</div>
</section>
<div v-else>
{{ $t('socialnetwork.forum.noTitles') }}
<div v-else class="forum-empty surface-card">
{{ $t('socialnetwork.forum.noTitles') }}
</div>
</div>
</template>
@@ -156,20 +168,60 @@ export default {
</script>
<style lang="scss" scoped>
.creationtoggler {
margin-bottom: 1em;
.forum-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.forum-hero {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 18px;
padding: 24px 26px;
margin-bottom: 16px;
}
.forum-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.forum-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.creationtoggler {
margin-bottom: 0;
}
.forum-creation,
.forum-topics,
.forum-empty {
padding: 22px;
}
.newtitle {
display: flex;
gap: 1em;
vertical-align: middle;
}
.newtitle input {
flex: 1;
flex-direction: column;
gap: 0.6em;
margin-bottom: 1rem;
}
.editor-container {
margin: 1em 0;
border: 1px solid #ccc;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 0;
min-height: 260px;
background-color: white;
@@ -189,16 +241,62 @@ export default {
.editor :deep(.ProseMirror p) { margin: 0 0 .6rem; }
.editor :deep(.ProseMirror p:first-child) { margin-top: 0; }
.editor :deep(.ProseMirror-focused) { outline: 2px solid rgba(100,150,255,.35); }
.topic-list {
list-style: none;
padding: 0;
margin: 0;
}
.topic-card + .topic-card {
margin-top: 12px;
}
.topic-card__main {
width: 100%;
justify-content: space-between;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--color-border);
box-shadow: none;
padding: 14px 16px;
border-radius: var(--radius-lg);
color: var(--color-text-primary);
}
.topic-card__main strong {
text-align: left;
}
.topic-card__meta {
color: var(--color-text-muted);
font-size: 0.82rem;
white-space: nowrap;
}
.pagination {
display: flex;
justify-content: center;
gap: 0.5em;
margin: 1em 0;
}
.pagination button {
padding: 0.5em 1em;
}
.pagination span {
padding: 0.5em;
}
.forum-empty {
color: var(--color-text-secondary);
text-align: center;
}
@media (max-width: 960px) {
.forum-hero {
flex-direction: column;
align-items: flex-start;
}
.topic-card__main {
align-items: flex-start;
}
}
</style>

View File

@@ -1,13 +1,29 @@
<template>
<div>
<h2>{{ $t('friends.title') }}</h2>
<div class="friends-view">
<section class="friends-hero surface-card">
<div>
<span class="friends-kicker">Community</span>
<h2>{{ $t('friends.title') }}</h2>
<p>Freundschaften, offene Anfragen und laufende Kontakte an einem Ort.</p>
</div>
<div class="friends-stats">
<div class="friends-stat surface-card">
<strong>{{ tabs[0].data.length }}</strong>
<span>Bestehend</span>
</div>
<div class="friends-stat surface-card">
<strong>{{ tabs[1].data.length + tabs[2].data.length }}</strong>
<span>Offen</span>
</div>
</div>
</section>
<div class="tabs-container">
<div class="tab" v-for="(tab, index) in tabs" :key="tab.name" :class="{ active: activeTab === index }"
@click="selectTab(index)">
{{ $t(tab.label) }}
</div>
</div>
<div v-for="(tab, index) in tabs" v-show="activeTab === index" :key="tab.name">
<div v-for="(tab, index) in tabs" v-show="activeTab === index" :key="tab.name" class="friends-panel surface-card">
<v-data-table :items="paginatedData(tab.data, tab.pagination.page)" :headers="headers"
:items-per-page="tab.pagination.itemsPerPage" class="elevation-1">
<template v-slot:body="{ items }">
@@ -167,25 +183,85 @@ export default {
</script>
<style scoped>
.friends-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.friends-hero {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 18px;
padding: 24px 26px;
margin-bottom: 16px;
}
.friends-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(120, 195, 138, 0.14);
color: #42634e;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.friends-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.friends-stats {
display: flex;
gap: 12px;
}
.friends-stat {
min-width: 120px;
padding: 14px 16px;
text-align: center;
}
.friends-stat strong {
display: block;
font-size: 1.5rem;
line-height: 1;
margin-bottom: 6px;
}
.friends-stat span {
color: var(--color-text-secondary);
font-size: 0.82rem;
font-weight: 600;
}
.tabs-container {
display: flex;
border-bottom: 1px solid #999;
padding: 5px 0;
gap: 8px;
padding: 0 0 12px;
border-bottom: 0;
flex-wrap: wrap;
}
.tab {
padding: 2px 4px;
padding: 8px 14px;
text-align: center;
cursor: pointer;
background-color: #fff;
color: #333;
background-color: rgba(255, 255, 255, 0.7);
color: var(--color-text-secondary);
font-weight: bold;
border: 1px solid #999;
border: 1px solid var(--color-border);
border-radius: 999px;
transition: background-color 0.3s ease, color 0.3s ease;
}
.tab:not(.active):hover {
background-color: #ddd;
background-color: rgba(248, 162, 43, 0.12);
}
.tab.active {
@@ -194,6 +270,10 @@ export default {
border-color: #F9A22C;
}
.friends-panel {
padding: 16px;
}
.font-color-gender-male {
color: #1E90FF;
}
@@ -205,4 +285,11 @@ export default {
.font-color-gender-nonbinary {
color: #DAA520;
}
@media (max-width: 960px) {
.friends-hero {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -1,7 +1,14 @@
<template>
<h2>{{ $t('socialnetwork.gallery.title') }}</h2>
<div class="gallery-view">
<div class="sidebar">
<div class="gallery-page">
<section class="gallery-hero surface-card">
<div>
<span class="gallery-kicker">Bilder und Ordner</span>
<h2>{{ $t('socialnetwork.gallery.title') }}</h2>
<p>Eigene Inhalte organisieren, sichtbar machen und in Ordnern strukturieren.</p>
</div>
</section>
<div class="gallery-view">
<div class="sidebar surface-card">
<h3>{{ $t('socialnetwork.gallery.folders') }}</h3>
<ul class="tree">
<folder-item v-for="folder in [folders]" :key="folder.id" :folder="folder"
@@ -13,7 +20,7 @@
</div>
<div class="content">
<div class="upload-section">
<div class="upload-section surface-card">
<div class="upload-header" @click="toggleUploadSection">
<span>
<i class="icon-upload-toggle">{{ isUploadVisible ? '&#9650;' : '&#9660;' }}</i>
@@ -63,9 +70,9 @@
</div>
</div>
<div class="image-list">
<div class="image-list surface-card">
<h3>{{ $t('socialnetwork.gallery.images') }}</h3>
<ul v-if="images.length > 0">
<ul v-if="images.length > 0" class="image-grid">
<li v-for="image in images" :key="image.id" @click="openImageDialog(image)">
<img :src="image.url || image.placeholder" alt="Loading..." />
<p>{{ image.title }}</p>
@@ -75,6 +82,7 @@
</div>
</div>
</div>
</div>
</template>
<script>
@@ -265,35 +273,95 @@ export default {
</script>
<style scoped>
.gallery-page {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.gallery-hero {
padding: 24px 26px;
margin-bottom: 16px;
}
.gallery-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(120, 195, 138, 0.14);
color: #42634e;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.gallery-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.gallery-view {
display: flex;
gap: 18px;
}
.sidebar {
width: 200px;
margin-right: 20px;
width: 240px;
margin-right: 0;
padding: 18px;
}
.content {
flex: 1;
min-width: 0;
}
.upload-section {
margin-bottom: 20px;
padding: 18px;
}
.image-list {
display: flex;
flex-direction: column;
flex-wrap: wrap;
padding: 18px;
}
.image-list li {
margin: 4px;
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
list-style-type: none;
padding: 0;
margin: 0;
}
.image-grid li {
margin: 0;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.72);
cursor: pointer;
}
.image-grid p {
text-align: center;
margin: 0;
}
.image-list li img {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: 12px;
margin-bottom: 10px;
}
.icon-upload-toggle {
float: left;
cursor: pointer;
}
@@ -302,51 +370,23 @@ export default {
width: auto;
}
.folder-item {
padding: 5px;
cursor: pointer;
}
.folder-item.selected {
background-color: lightgray;
}
.image-list > ul {
list-style-type: none;
padding: 0;
}
.image-list > ul > li {
display: inline-block;
padding: 2px;
border: 1px solid #F9A22C;
}
.image-list > ul > li > p {
text-align: center;
}
.image-list li img {
max-width: 200px;
max-height: 200px;
object-fit: contain;
cursor: pointer;
}
.icon {
cursor: pointer;
margin-left: 10px;
}
.edit-icon {
color: green;
}
.delete-icon {
color: red;
.upload-header {
display: flex;
align-items: center;
gap: 10px;
}
.tree {
padding: 0;
}
@media (max-width: 960px) {
.gallery-view {
flex-direction: column;
}
.sidebar {
width: auto;
}
}
</style>

View File

@@ -1,12 +1,18 @@
<template>
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2>
<div>
<div v-if="guestbookEntries.length === 0">{{ $t('socialnetwork.profile.guestbook.noEntries') }}
<div class="guestbook-view">
<section class="guestbook-hero surface-card">
<div>
<span class="guestbook-kicker">Gaestebuch</span>
<h2>{{ $t('socialnetwork.guestbook.title') }}</h2>
<p>Nachrichten, Rueckmeldungen und kleine Einblicke aus deinem Netzwerk.</p>
</div>
</section>
<div v-if="guestbookEntries.length === 0" class="guestbook-empty surface-card">{{ $t('socialnetwork.profile.guestbook.noEntries') }}
</div>
<div v-else class="guestbook-entries">
<div v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry">
<article v-for="entry in guestbookEntries" :key="entry.id" class="guestbook-entry surface-card">
<img v-if="entry.image" :src="entry.image.url" alt="Entry Image"
style="max-width: 400px; max-height: 400px;" />
class="guestbook-image" />
<p v-html="sanitizedContent(entry)"></p>
<div class="entry-info">
<span class="entry-timestamp">{{ new Date(entry.createdAt).toLocaleString() }}</span>
@@ -14,7 +20,7 @@
<span @click="openProfile(entry.senderUsername)">{{ entry.sender }}</span>
</span>
</div>
</div>
</article>
</div>
<div class="pagination">
<button @click="loadGuestbookEntries(currentPage - 1)" v-if="currentPage !== 1">{{
@@ -85,10 +91,72 @@ export default {
</script>
<style lang="css" scoped>
.guestbook-view {
max-width: 820px;
margin: 0 auto;
padding-bottom: 24px;
}
.guestbook-hero,
.guestbook-empty {
padding: 22px;
margin-bottom: 16px;
}
.guestbook-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(120, 195, 138, 0.14);
color: #42634e;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.guestbook-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.guestbook-entries {
display: grid;
gap: 12px;
}
.guestbook-entry {
padding: 18px 20px;
}
.guestbook-image {
max-width: 100%;
max-height: 400px;
border-radius: 14px;
margin-bottom: 12px;
}
.entry-info {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--color-text-muted);
}
.entry-user span {
cursor: pointer;
font-weight: 700;
color: var(--color-primary);
}
.pagination {
margin-top: 1em;
background-color: #7BBE55;
color: #fff;
color: var(--color-text-secondary);
padding: 0.5em 0;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
}
</style>
</style>

View File

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

View File

@@ -1,53 +1,59 @@
<template>
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
<div class="vocab-chapter-view">
<section class="vocab-chapter-hero surface-card">
<span class="vocab-chapter-hero__eyebrow">Vokabeltrainer</span>
<h2>{{ $t('socialnetwork.vocab.chapterTitle', { title: chapter?.title || '' }) }}</h2>
<p>Kapitelinhalt durchsuchen, Vokabeln pflegen und direkt in die Uebung wechseln.</p>
</section>
<div class="box">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<div v-show="!practiceOpen">
<div class="row">
<button @click="back">{{ $t('general.back') }}</button>
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div>
<div class="row" v-if="chapter.isOwner">
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
<div class="grid">
<label>
{{ $t('socialnetwork.vocab.learningWord') }}
<input v-model="learning" type="text" />
</label>
<label>
{{ $t('socialnetwork.vocab.referenceWord') }}
<input v-model="reference" type="text" />
</label>
<section class="box surface-card">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!chapter">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<div v-show="!practiceOpen">
<div class="row row--actions">
<button @click="back">{{ $t('general.back') }}</button>
<button v-if="vocabs.length" @click="openPractice">{{ $t('socialnetwork.vocab.practice.open') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div>
<div class="editor-card" v-if="chapter.isOwner">
<h3>{{ $t('socialnetwork.vocab.addVocab') }}</h3>
<div class="grid">
<label>
<span>{{ $t('socialnetwork.vocab.learningWord') }}</span>
<input v-model="learning" type="text" />
</label>
<label>
<span>{{ $t('socialnetwork.vocab.referenceWord') }}</span>
<input v-model="reference" type="text" />
</label>
</div>
<button :disabled="saving || !canSave" @click="add">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.add') }}
</button>
</div>
<div v-if="vocabs.length === 0" class="empty-state">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
<div v-else class="table-wrap">
<table class="tbl">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
<th>{{ $t('socialnetwork.vocab.referenceWord') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in vocabs" :key="v.id">
<td>{{ v.learning }}</td>
<td>{{ v.reference }}</td>
</tr>
</tbody>
</table>
</div>
<button :disabled="saving || !canSave" @click="add">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.add') }}
</button>
</div>
<hr />
<div v-if="vocabs.length === 0">{{ $t('socialnetwork.vocab.noVocabs') }}</div>
<table v-else class="tbl">
<thead>
<tr>
<th>{{ $t('socialnetwork.vocab.learningWord') }}</th>
<th>{{ $t('socialnetwork.vocab.referenceWord') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="v in vocabs" :key="v.id">
<td>{{ v.learning }}</td>
<td>{{ v.reference }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<VocabPracticeDialog ref="practiceDialog" />
@@ -147,30 +153,120 @@ export default {
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
display: inline-block;
.vocab-chapter-view {
display: grid;
gap: 18px;
}
.vocab-chapter-hero,
.box {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.vocab-chapter-hero,
.box {
padding: 22px 24px;
}
.vocab-chapter-hero__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.vocab-chapter-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.row {
margin-bottom: 10px;
}
.row--actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.editor-card {
display: grid;
gap: 14px;
margin: 18px 0 20px;
padding: 18px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.62);
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 8px;
}
.grid label {
display: grid;
gap: 8px;
}
.grid span {
font-weight: 600;
color: var(--color-text-secondary);
}
.empty-state {
padding: 18px;
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
background: rgba(255, 255, 255, 0.5);
}
.table-wrap {
overflow-x: auto;
}
.tbl {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.68);
border-radius: var(--radius-md);
overflow: hidden;
}
.tbl th,
.tbl td {
border: 1px solid #ccc;
padding: 6px;
border: 1px solid var(--color-border);
padding: 10px 12px;
text-align: left;
}
.tbl th {
background: rgba(248, 162, 43, 0.12);
color: var(--color-text-secondary);
font-weight: 700;
}
@media (max-width: 760px) {
.vocab-chapter-hero,
.box {
padding: 18px;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,8 +1,14 @@
<template>
<div class="vocab-course-list">
<h2>{{ $t('socialnetwork.vocab.courses.title') }}</h2>
<section class="vocab-courses-hero surface-card">
<div>
<span class="vocab-courses-kicker">Kurse</span>
<h2>{{ $t('socialnetwork.vocab.courses.title') }}</h2>
<p>Oeffentliche und eigene Lernkurse filtern, finden und direkt weiterlernen.</p>
</div>
</section>
<div class="box">
<div class="box surface-card">
<div class="actions">
<button @click="showCreateDialog = true">{{ $t('socialnetwork.vocab.courses.create') }}</button>
<button @click="loadMyCourses">{{ $t('socialnetwork.vocab.courses.myCourses') }}</button>
@@ -361,14 +367,37 @@ export default {
<style scoped>
.vocab-course-list {
padding: 20px;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 0 24px;
}
.vocab-courses-hero {
padding: 24px 26px;
margin-bottom: 16px;
}
.vocab-courses-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(120, 195, 138, 0.14);
color: #42634e;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.vocab-courses-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 18px;
border-radius: var(--radius-lg);
}
.actions {

View File

@@ -1,11 +1,16 @@
<template>
<div class="vocab-course-view">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-if="loading" class="surface-card course-state">{{ $t('general.loading') }}</div>
<div v-else-if="course">
<h2>{{ course.title }}</h2>
<p v-if="course.description">{{ course.description }}</p>
<section class="course-hero surface-card">
<div>
<span class="course-kicker">Lernkurs</span>
<h2>{{ course.title }}</h2>
<p v-if="course.description">{{ course.description }}</p>
</div>
</section>
<div class="course-info">
<div class="course-info surface-card">
<span>{{ $t('socialnetwork.vocab.courses.difficulty') }}: {{ course.difficultyLevel }}</span>
<span v-if="course.isPublic">{{ $t('socialnetwork.vocab.courses.public') }}</span>
<span v-if="course.shareCode && isOwner" class="share-code">
@@ -18,7 +23,7 @@
<button @click="editCourse">{{ $t('socialnetwork.vocab.courses.edit') }}</button>
</div>
<div v-if="course.lessons && course.lessons.length > 0" class="lessons-list">
<div v-if="course.lessons && course.lessons.length > 0" class="lessons-list surface-card">
<div class="current-lesson-section" v-if="currentLesson">
<button @click="openLesson(currentLesson.id)" class="btn-current-lesson">
{{ $t('socialnetwork.vocab.courses.continueCurrentLesson') }}
@@ -75,7 +80,7 @@
</table>
</div>
<div v-else>
<p>{{ $t('socialnetwork.vocab.courses.noLessons') }}</p>
<p class="surface-card course-state">{{ $t('socialnetwork.vocab.courses.noLessons') }}</p>
</div>
</div>
@@ -84,28 +89,29 @@
<div class="dialog" @click.stop>
<h3>{{ $t('socialnetwork.vocab.courses.addLesson') }}</h3>
<form @submit.prevent="addLesson">
<div class="form-group">
<div class="form-group form-field">
<label>{{ $t('socialnetwork.vocab.courses.lessonNumber') }}</label>
<input type="number" v-model.number="newLesson.lessonNumber" min="1" required />
<input type="number" v-model.number="newLesson.lessonNumber" min="1" required :class="{ 'field-error': lessonFormTouched && !isLessonNumberValid }" />
</div>
<div class="form-group">
<div class="form-group form-field">
<label>{{ $t('socialnetwork.vocab.courses.title') }}</label>
<input v-model="newLesson.title" required />
<input v-model="newLesson.title" required :class="{ 'field-error': lessonFormTouched && !isLessonTitleValid }" />
</div>
<div class="form-group">
<div class="form-group form-field">
<label>{{ $t('socialnetwork.vocab.courses.description') }}</label>
<textarea v-model="newLesson.description"></textarea>
</div>
<div class="form-group">
<div class="form-group form-field">
<label>{{ $t('socialnetwork.vocab.courses.chapter') }}</label>
<select v-model="newLesson.chapterId" required>
<select v-model="newLesson.chapterId" required :class="{ 'field-error': lessonFormTouched && !isLessonChapterValid }">
<option value="">{{ $t('socialnetwork.vocab.courses.selectChapter') }}</option>
<option v-for="chapter in chapters" :key="chapter.id" :value="chapter.id">{{ chapter.title }}</option>
</select>
</div>
<div class="form-actions">
<button type="submit">{{ $t('general.create') }}</button>
<button type="button" @click="showAddLessonDialog = false">{{ $t('general.cancel') }}</button>
<span v-if="lessonFormTouched && !canCreateLesson" class="form-error">Bitte Nummer, Titel und Kapitel vollstaendig angeben.</span>
<div class="form-actions form-actions-row">
<button type="submit" :disabled="!canCreateLesson">{{ $t('general.create') }}</button>
<button type="button" @click="showAddLessonDialog = false" class="button-secondary">{{ $t('general.cancel') }}</button>
</div>
</form>
</div>
@@ -116,6 +122,7 @@
<script>
import { mapGetters } from 'vuex';
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'VocabCourseView',
@@ -132,6 +139,7 @@ export default {
progress: [],
chapters: [],
showAddLessonDialog: false,
lessonFormTouched: false,
newLesson: {
lessonNumber: 1,
title: '',
@@ -163,6 +171,18 @@ export default {
// Alle Lektionen abgeschlossen - zeige die letzte Lektion
return sortedLessons[sortedLessons.length - 1];
},
isLessonNumberValid() {
return Number(this.newLesson.lessonNumber) > 0;
},
isLessonTitleValid() {
return this.newLesson.title.trim().length >= 3;
},
isLessonChapterValid() {
return Boolean(this.newLesson.chapterId);
},
canCreateLesson() {
return this.isLessonNumberValid && this.isLessonTitleValid && this.isLessonChapterValid;
}
},
watch: {
@@ -232,9 +252,14 @@ export default {
return false;
},
async addLesson() {
this.lessonFormTouched = true;
if (!this.canCreateLesson) {
return;
}
try {
await apiClient.post(`/api/vocab/courses/${this.courseId}/lessons`, this.newLesson);
this.showAddLessonDialog = false;
this.lessonFormTouched = false;
this.newLesson = {
lessonNumber: 1,
title: '',
@@ -242,9 +267,10 @@ export default {
chapterId: null
};
await this.loadCourse();
showSuccess(this, 'Lektion erfolgreich angelegt.');
} catch (e) {
console.error('Fehler beim Hinzufügen der Lektion:', e);
alert(e.response?.data?.error || 'Fehler beim Hinzufügen der Lektion');
showApiError(this, e, 'Fehler beim Hinzufuegen der Lektion');
}
},
async deleteLesson(lessonId) {
@@ -254,9 +280,10 @@ export default {
try {
await apiClient.delete(`/api/vocab/lessons/${lessonId}`);
await this.loadCourse();
showSuccess(this, 'Lektion erfolgreich geloescht.');
} catch (e) {
console.error('Fehler beim Löschen der Lektion:', e);
alert(e.response?.data?.error || 'Fehler beim Löschen der Lektion');
showApiError(this, e, 'Fehler beim Loeschen der Lektion');
}
},
openLesson(lessonId) {
@@ -278,15 +305,47 @@ export default {
<style scoped>
.vocab-course-view {
padding: 20px;
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 0 24px;
}
.course-hero,
.course-info,
.lessons-list,
.course-state {
margin-bottom: 16px;
}
.course-hero {
padding: 24px 26px;
}
.course-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.course-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.course-info {
display: flex;
gap: 15px;
margin: 15px 0;
margin: 0 0 16px;
color: #666;
flex-wrap: wrap;
padding: 16px 18px;
}
.share-code {
@@ -307,7 +366,14 @@ export default {
}
.lessons-list {
margin-top: 30px;
margin-top: 0;
padding: 20px;
}
.course-state {
padding: 18px;
text-align: center;
color: var(--color-text-secondary);
}
.current-lesson-section {

View File

@@ -1,32 +1,36 @@
<template>
<h2>{{ $t('socialnetwork.vocab.languageTitle', { name: language?.name || '' }) }}</h2>
<div class="vocab-language-view">
<section class="vocab-language-hero surface-card">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!language">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<span class="vocab-language-kicker">Sprache</span>
<h2>{{ $t('socialnetwork.vocab.languageTitle', { name: language?.name || '' }) }}</h2>
<p>Kapitel, Suchfunktionen und Freigaben fuer diese Sprache an einem Ort.</p>
</div>
</section>
<div class="box">
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else-if="!language">{{ $t('socialnetwork.vocab.notFound') }}</div>
<div v-else>
<div class="row">
<strong>{{ $t('socialnetwork.vocab.languageName') }}:</strong>
<div class="box surface-card" v-if="language">
<div class="row row--meta">
<strong>{{ $t('socialnetwork.vocab.languageName') }}</strong>
<span>{{ language.name }}</span>
</div>
<div class="row" v-if="language.isOwner && language.shareCode">
<strong>{{ $t('socialnetwork.vocab.shareCode') }}:</strong>
<div class="row row--meta" v-if="language.isOwner && language.shareCode">
<strong>{{ $t('socialnetwork.vocab.shareCode') }}</strong>
<code>{{ language.shareCode }}</code>
</div>
<div class="row">
<div class="row row--actions">
<button @click="goSubscribe">{{ $t('socialnetwork.vocab.subscribeByCode') }}</button>
<button @click="openSearch">{{ $t('socialnetwork.vocab.search.open') }}</button>
</div>
<hr />
<div class="row">
<h3>{{ $t('socialnetwork.vocab.chapters') }}</h3>
</div>
<div class="row" v-if="language.isOwner">
<div class="row row--create" v-if="language.isOwner">
<label>
{{ $t('socialnetwork.vocab.newChapter') }}
<input v-model="newChapterTitle" type="text" />
@@ -39,11 +43,12 @@
<div v-if="chaptersLoading">{{ $t('general.loading') }}</div>
<div v-else>
<div v-if="chapters.length === 0">{{ $t('socialnetwork.vocab.noChapters') }}</div>
<ul v-else>
<ul v-else class="chapter-list">
<li v-for="c in chapters" :key="c.id">
<span class="click" @click="openChapter(c.id)">
{{ c.title }} <span class="count">({{ c.vocabCount }})</span>
</span>
<button type="button" class="chapter-card" @click="openChapter(c.id)">
<span>{{ c.title }}</span>
<span class="count">{{ c.vocabCount }}</span>
</button>
</li>
</ul>
</div>
@@ -139,22 +144,81 @@ export default {
</script>
<style scoped>
.vocab-language-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.vocab-language-hero {
padding: 24px 26px;
margin-bottom: 16px;
}
.vocab-language-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.vocab-language-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
padding: 20px;
}
.row {
margin-bottom: 8px;
margin-bottom: 12px;
}
.row--meta {
display: flex;
gap: 10px;
align-items: center;
}
.row--actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.row--create {
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
}
.click {
cursor: pointer;
text-decoration: underline;
}
.chapter-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.chapter-card {
width: 100%;
justify-content: space-between;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--color-border);
box-shadow: none;
padding: 14px 16px;
border-radius: var(--radius-lg);
color: var(--color-text-primary);
}
.count {
color: #666;
color: var(--color-text-muted);
font-size: 0.9em;
}
</style>

View File

@@ -1,40 +1,50 @@
<template>
<h2>{{ $t('socialnetwork.vocab.newLanguageTitle') }}</h2>
<div class="vocab-new-language-view">
<section class="vocab-new-language-hero surface-card">
<span class="vocab-new-language-hero__eyebrow">Vokabeltrainer</span>
<h2>{{ $t('socialnetwork.vocab.newLanguageTitle') }}</h2>
<p>Neue Sprache anlegen, Freigabecode erzeugen und direkt in die Bearbeitung wechseln.</p>
</section>
<div class="box">
<label class="label">
{{ $t('socialnetwork.vocab.languageName') }}
<input v-model="name" type="text" />
</label>
<section class="box surface-card">
<label class="label form-field">
<span>{{ $t('socialnetwork.vocab.languageName') }}</span>
<input v-model="name" type="text" :class="{ 'field-error': nameTouched && !canSave }" />
<span class="form-hint">Ein kurzer, klarer Sprachname reicht fuer den Start.</span>
<span v-if="nameTouched && !canSave" class="form-error">Der Name sollte mindestens 2 Zeichen haben.</span>
</label>
<div class="actions">
<button :disabled="saving || !canSave" @click="create">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.create') }}
</button>
<button :disabled="saving" @click="cancel">{{ $t('Cancel') }}</button>
</div>
<div v-if="created" class="created">
<div><strong>{{ $t('socialnetwork.vocab.created') }}</strong></div>
<div>
{{ $t('socialnetwork.vocab.shareCode') }}:
<code>{{ created.shareCode }}</code>
<div class="actions form-actions-row">
<button :disabled="saving || !canSave" @click="create">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.create') }}
</button>
<button :disabled="saving" @click="cancel" class="button-secondary">{{ $t('Cancel') }}</button>
</div>
<div class="hint">{{ $t('socialnetwork.vocab.shareHint') }}</div>
<button @click="openLanguage(created.id)">{{ $t('socialnetwork.vocab.openLanguage') }}</button>
</div>
<div v-if="created" class="created">
<div><strong>{{ $t('socialnetwork.vocab.created') }}</strong></div>
<div>
{{ $t('socialnetwork.vocab.shareCode') }}:
<code>{{ created.shareCode }}</code>
</div>
<div class="hint">{{ $t('socialnetwork.vocab.shareHint') }}</div>
<button @click="openLanguage(created.id)">{{ $t('socialnetwork.vocab.openLanguage') }}</button>
</div>
</section>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'VocabNewLanguageView',
data() {
return {
name: '',
nameTouched: false,
saving: false,
created: null,
};
@@ -53,22 +63,20 @@ export default {
this.$router.push(`/socialnetwork/vocab/${id}`);
},
async create() {
this.nameTouched = true;
if (!this.canSave) {
return;
}
this.saving = true;
try {
const res = await apiClient.post('/api/vocab/languages', { name: this.name });
this.created = res.data;
// Menü sofort lokal aktualisieren (zusätzlich zum serverseitigen reloadmenu event)
try { await this.loadMenu(); } catch (_) {}
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createdMessage'),
this.$t('socialnetwork.vocab.createdTitle')
);
showSuccess(this, this.$t('socialnetwork.vocab.createdMessage'), this.$t('socialnetwork.vocab.createdTitle'));
} catch (e) {
console.error('Create vocab language failed:', e);
this.$root.$refs.messageDialog?.open(
this.$t('socialnetwork.vocab.createError'),
this.$t('error.title')
);
showApiError(this, e, this.$t('socialnetwork.vocab.createError'));
} finally {
this.saving = false;
}
@@ -78,29 +86,88 @@ export default {
</script>
<style scoped>
.vocab-new-language-view {
display: grid;
gap: 18px;
max-width: 760px;
}
.vocab-new-language-hero,
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.vocab-new-language-hero,
.box {
padding: 22px 24px;
}
.vocab-new-language-hero__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.vocab-new-language-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.label {
display: block;
margin-bottom: 10px;
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.label span {
font-weight: 600;
color: var(--color-text-secondary);
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.created {
margin-top: 12px;
padding: 10px;
background: #fff;
border: 1px solid #bbb;
display: grid;
gap: 8px;
margin-top: 18px;
padding: 18px;
background: rgba(255, 255, 255, 0.68);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.hint {
margin-top: 6px;
color: #555;
color: var(--color-text-secondary);
}
code {
padding: 2px 8px;
border-radius: 999px;
background: var(--color-primary-soft);
color: var(--color-text-primary);
}
@media (max-width: 760px) {
.vocab-new-language-hero,
.box {
padding: 18px;
}
.actions button,
.created button {
width: 100%;
}
}
</style>

View File

@@ -1,20 +1,24 @@
<template>
<h2>{{ $t('socialnetwork.vocab.subscribeTitle') }}</h2>
<div class="vocab-subscribe-view">
<section class="vocab-subscribe-hero surface-card">
<span class="vocab-subscribe-hero__eyebrow">Vokabeltrainer</span>
<h2>{{ $t('socialnetwork.vocab.subscribeTitle') }}</h2>
<p>{{ $t('socialnetwork.vocab.subscribeHint') }}</p>
</section>
<div class="box">
<p>{{ $t('socialnetwork.vocab.subscribeHint') }}</p>
<section class="box surface-card">
<label class="label">
<span>{{ $t('socialnetwork.vocab.shareCode') }}</span>
<input v-model="shareCode" type="text" />
</label>
<label class="label">
{{ $t('socialnetwork.vocab.shareCode') }}
<input v-model="shareCode" type="text" />
</label>
<div class="actions">
<button :disabled="saving || !canSave" @click="subscribe">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.subscribe') }}
</button>
<button :disabled="saving" @click="back">{{ $t('general.back') }}</button>
</div>
<div class="actions">
<button :disabled="saving || !canSave" @click="subscribe">
{{ saving ? $t('socialnetwork.vocab.saving') : $t('socialnetwork.vocab.subscribe') }}
</button>
<button :disabled="saving" @click="back">{{ $t('general.back') }}</button>
</div>
</section>
</div>
</template>
@@ -75,19 +79,69 @@ export default {
</script>
<style scoped>
.vocab-subscribe-view {
display: grid;
gap: 18px;
max-width: 720px;
}
.vocab-subscribe-hero,
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
}
.vocab-subscribe-hero,
.box {
padding: 22px 24px;
}
.vocab-subscribe-hero__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.vocab-subscribe-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.label {
display: block;
margin-bottom: 10px;
display: grid;
gap: 8px;
margin-bottom: 16px;
}
.label span {
font-weight: 600;
color: var(--color-text-secondary);
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
@media (max-width: 760px) {
.vocab-subscribe-hero,
.box {
padding: 18px;
}
.actions button {
width: 100%;
}
}
</style>

View File

@@ -1,27 +1,101 @@
<template>
<h2>{{ $t('socialnetwork.vocab.title') }}</h2>
<div class="vocab-view">
<section class="vocab-hero surface-card">
<div>
<span class="vocab-kicker">Sprachenlernen</span>
<h2>{{ $t('socialnetwork.vocab.title') }}</h2>
<p>{{ $t('socialnetwork.vocab.description') }}</p>
</div>
<div class="actions">
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
<button @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
</div>
</section>
<div class="box">
<p>{{ $t('socialnetwork.vocab.description') }}</p>
<section class="vocab-summary-grid">
<article class="summary-card surface-card">
<span class="summary-card__label">Sprachen gesamt</span>
<strong>{{ languages.length }}</strong>
<p>Alle aktiven Sprachbereiche, in denen du Inhalte nutzt oder verwaltest.</p>
</article>
<article class="summary-card surface-card">
<span class="summary-card__label">Eigene Bereiche</span>
<strong>{{ ownedLanguages.length }}</strong>
<p>Hier legst du Inhalte, Kapitel und Lernmaterial aktiv selbst an.</p>
</article>
<article class="summary-card surface-card">
<span class="summary-card__label">Abonniert</span>
<strong>{{ subscribedLanguages.length }}</strong>
<p>Diese Bereiche sind eher fuer Lernen und Fortschritt statt Verwaltung gedacht.</p>
</article>
</section>
<div class="actions">
<button @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
<button @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
</div>
<section class="vocab-task-grid">
<article class="task-card surface-card">
<span class="task-card__eyebrow">Schnellstart</span>
<h3>Neue Sprache anlegen</h3>
<p>Der beste Einstieg, wenn du Inhalte selbst strukturieren und pflegen willst.</p>
<button type="button" @click="goNewLanguage">{{ $t('socialnetwork.vocab.newLanguage') }}</button>
</article>
<article class="task-card surface-card">
<span class="task-card__eyebrow">Weiterlernen</span>
<h3>Kurse und Kapitel oeffnen</h3>
<p>Springe direkt in bestehende Lernpfade und arbeite mit vorhandenen Kursen weiter.</p>
<button type="button" class="button-secondary" @click="goCourses">{{ $t('socialnetwork.vocab.courses.title') }}</button>
</article>
</section>
<div v-if="loading">{{ $t('general.loading') }}</div>
<div v-else>
<div v-if="languages.length === 0">
<section class="vocab-box surface-card">
<div v-if="loading" class="vocab-state">{{ $t('general.loading') }}</div>
<div v-else-if="languages.length === 0" class="vocab-state">
{{ $t('socialnetwork.vocab.none') }}
</div>
<ul v-else>
<li v-for="l in languages" :key="l.id">
<span class="langname" @click="openLanguage(l.id)">{{ l.name }}</span>
<span class="role" v-if="l.isOwner">({{ $t('socialnetwork.vocab.owner') }})</span>
<span class="role" v-else>({{ $t('socialnetwork.vocab.subscribed') }})</span>
</li>
</ul>
</div>
<div v-else class="language-sections">
<section class="language-section">
<div class="language-section__header">
<div>
<h3>Eigene Sprachen</h3>
<p>Direkter Einstieg in Bearbeitung, Kapitel und Kursverwaltung.</p>
</div>
<span class="language-section__count">{{ ownedLanguages.length }}</span>
</div>
<ul v-if="ownedLanguages.length" class="language-list">
<li v-for="l in ownedLanguages" :key="l.id" class="language-card">
<button type="button" class="language-card__main" @click="openLanguage(l.id)">
<div class="language-card__info">
<span class="langname">{{ l.name }}</span>
<span class="language-card__hint">Verwalten und Inhalte pflegen</span>
</div>
<span class="role">{{ $t('socialnetwork.vocab.owner') }}</span>
</button>
</li>
</ul>
<p v-else class="language-empty">Noch keine eigenen Sprachbereiche vorhanden.</p>
</section>
<section class="language-section">
<div class="language-section__header">
<div>
<h3>Abonnierte Sprachen</h3>
<p>Gut fuer schnellen Wiedereinstieg ins Lernen ohne Verwaltungsaufwand.</p>
</div>
<span class="language-section__count">{{ subscribedLanguages.length }}</span>
</div>
<ul v-if="subscribedLanguages.length" class="language-list">
<li v-for="l in subscribedLanguages" :key="l.id" class="language-card">
<button type="button" class="language-card__main" @click="openLanguage(l.id)">
<div class="language-card__info">
<span class="langname">{{ l.name }}</span>
<span class="language-card__hint">Lernen, ueben und Fortschritt ansehen</span>
</div>
<span class="role">{{ $t('socialnetwork.vocab.subscribed') }}</span>
</button>
</li>
</ul>
<p v-else class="language-empty">Keine abonnierten Sprachen vorhanden.</p>
</section>
</div>
</section>
</div>
</template>
@@ -39,6 +113,12 @@ export default {
},
computed: {
...mapGetters(['user']),
ownedLanguages() {
return this.languages.filter((language) => language.isOwner);
},
subscribedLanguages() {
return this.languages.filter((language) => !language.isOwner);
},
},
methods: {
goNewLanguage() {
@@ -69,22 +149,197 @@ export default {
</script>
<style scoped>
.box {
background: #f6f6f6;
padding: 12px;
border: 1px solid #ccc;
.vocab-view {
max-width: var(--content-max-width);
margin: 0 auto;
padding-bottom: 24px;
}
.vocab-hero {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 18px;
padding: 24px 26px;
margin-bottom: 16px;
}
.vocab-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.14);
color: #8a5411;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.vocab-hero p {
margin: 0;
color: var(--color-text-secondary);
}
.vocab-box {
padding: 20px;
}
.vocab-summary-grid,
.vocab-task-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.vocab-task-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.summary-card,
.task-card {
padding: 18px;
}
.summary-card strong {
display: block;
margin: 6px 0 10px;
font-size: 1.8rem;
line-height: 1;
}
.summary-card p,
.task-card p {
margin: 0;
color: var(--color-text-secondary);
}
.summary-card__label,
.task-card__eyebrow {
display: inline-flex;
margin-bottom: 4px;
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.task-card h3 {
margin: 0 0 8px;
}
.task-card button {
margin-top: 14px;
}
.actions {
margin: 10px 0;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.vocab-state {
text-align: center;
color: var(--color-text-secondary);
padding: 18px;
}
.language-sections {
display: grid;
gap: 20px;
}
.language-section {
display: grid;
gap: 14px;
}
.language-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.language-section__header h3 {
margin: 0 0 4px;
}
.language-section__header p,
.language-empty {
margin: 0;
color: var(--color-text-secondary);
}
.language-section__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 10px;
border-radius: 999px;
background: rgba(248, 162, 43, 0.12);
color: #8a5411;
font-weight: 700;
}
.language-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.language-card__main {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--color-border);
box-shadow: none;
padding: 14px 16px;
border-radius: var(--radius-lg);
color: var(--color-text-primary);
}
.language-card__info {
display: grid;
gap: 3px;
text-align: left;
}
.langname {
cursor: pointer;
text-decoration: underline;
font-weight: 700;
}
.language-card__hint {
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.role {
margin-left: 6px;
color: #666;
color: var(--color-text-muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
@media (max-width: 960px) {
.vocab-hero {
flex-direction: column;
align-items: flex-start;
}
.vocab-summary-grid,
.vocab-task-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -30,6 +30,9 @@ export default defineConfig(({ mode }) => {
}),
...(combinedEnv.VITE_SOCKET_IO_URL && {
'import.meta.env.VITE_SOCKET_IO_URL': JSON.stringify(combinedEnv.VITE_SOCKET_IO_URL)
}),
...(combinedEnv.VITE_PUBLIC_BASE_URL && {
'import.meta.env.VITE_PUBLIC_BASE_URL': JSON.stringify(combinedEnv.VITE_PUBLIC_BASE_URL)
})
},
optimizeDeps: {
@@ -56,8 +59,35 @@ export default defineConfig(({ mode }) => {
build: {
rollupOptions: {
output: {
// Stelle sicher, dass three nicht externalisiert wird
manualChunks: undefined
manualChunks(id) {
if (!id.includes('node_modules')) {
return;
}
if (id.includes('@tiptap')) {
return 'vendor-editor';
}
if (id.includes('vuetify')) {
return 'vendor-vuetify';
}
if (id.includes('socket.io-client')) {
return 'vendor-realtime';
}
if (id.includes('axios') || id.includes('dompurify')) {
return 'vendor-utils';
}
if (id.includes('vue-router') || id.includes('vuex') || id.includes('vue-i18n')) {
return 'vendor-vue-ecosystem';
}
if (id.includes('vue')) {
return 'vendor-vue';
}
}
},
external: [] // Explizit keine externen Module
},