Compare commits
18 Commits
e054d90eb1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8e1eb4e25 | ||
|
|
c847f249ba | ||
|
|
8319b43835 | ||
|
|
47373a27af | ||
|
|
0205352ae9 | ||
|
|
8f3cbc16b8 | ||
|
|
6d17afe3a1 | ||
|
|
1e092a7232 | ||
|
|
e3928e0b65 | ||
|
|
6158c9d3c0 | ||
|
|
39fd5e9290 | ||
|
|
448f2ffb6f | ||
|
|
51040391e8 | ||
|
|
bcb3b5b71f | ||
|
|
83110659db | ||
|
|
c7ea33fb2c | ||
|
|
527cea1261 | ||
|
|
aabf162f04 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
.git/*
|
.git/*
|
||||||
build/*
|
build/*
|
||||||
logs/*.log
|
logs/*.log
|
||||||
|
logs/chat-users.json
|
||||||
|
|
||||||
# Build-Artefakte (werden auf dem Server neu gebaut)
|
# Build-Artefakte (werden auf dem Server neu gebaut)
|
||||||
client/dist/
|
client/dist/
|
||||||
|
|||||||
130
ADSENSE.md
Normal file
130
ADSENSE.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# AdSense in SingleChat
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Im Header kann ein Google-AdSense-Banner eingeblendet werden. Die Einbindung ist bereits vorbereitet, aber nur aktiv, wenn die passenden Vite-Variablen gesetzt sind.
|
||||||
|
|
||||||
|
## Bereits im Code vorbereitet
|
||||||
|
|
||||||
|
- Header-Komponente: [HeaderAdBanner.vue](/mnt/share/torsten/Programs/SingleChat/client/src/components/HeaderAdBanner.vue)
|
||||||
|
- Einbindung in die Kopfzeilen:
|
||||||
|
- [ChatView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/ChatView.vue)
|
||||||
|
- [PartnersView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/PartnersView.vue)
|
||||||
|
- [FeedbackView.vue](/mnt/share/torsten/Programs/SingleChat/client/src/views/FeedbackView.vue)
|
||||||
|
|
||||||
|
Aktiv wird der Banner nur mit:
|
||||||
|
|
||||||
|
- `VITE_ADSENSE_CLIENT`
|
||||||
|
- `VITE_ADSENSE_HEADER_SLOT`
|
||||||
|
|
||||||
|
## Was bei Google AdSense erledigt werden muss
|
||||||
|
|
||||||
|
### 1. AdSense-Konto und Website
|
||||||
|
|
||||||
|
In AdSense selbst:
|
||||||
|
|
||||||
|
1. Website `ypchat.net` hinzufügen
|
||||||
|
2. Eigentum/Einbindung abschließen
|
||||||
|
3. Warten, bis die Website von Google geprüft und freigegeben wurde
|
||||||
|
|
||||||
|
Ohne freigegebene Website werden in der Regel keine regulären Anzeigen ausgeliefert.
|
||||||
|
|
||||||
|
### 2. Anzeigenblock anlegen
|
||||||
|
|
||||||
|
Für den Header in AdSense einen normalen responsiven Display-Anzeigenblock anlegen.
|
||||||
|
|
||||||
|
Benötigt werden daraus:
|
||||||
|
|
||||||
|
- Publisher-ID
|
||||||
|
Beispiel: `ca-pub-1234567890123456`
|
||||||
|
- Slot-ID des Header-Anzeigenblocks
|
||||||
|
Beispiel: `1234567890`
|
||||||
|
|
||||||
|
### 3. `ads.txt` korrekt pflegen
|
||||||
|
|
||||||
|
AdSense erwartet in der Regel einen korrekten Eintrag in `/ads.txt`.
|
||||||
|
|
||||||
|
Für Google AdSense ist das Format typischerweise:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
google.com, pub-1234567890123456, DIRECT, f08c47fec0942fa0
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- `pub-...` muss zu deinem echten AdSense-Konto passen
|
||||||
|
- die Datei muss öffentlich unter `https://ypchat.net/ads.txt` erreichbar sein
|
||||||
|
- Änderungen brauchen oft etwas Zeit, bis Google sie erkennt
|
||||||
|
|
||||||
|
Im Projekt liegt aktuell eine Datei unter [docroot/ads.txt](/mnt/share/torsten/Programs/SingleChat/docroot/ads.txt). Diese muss auf deine echte Publisher-ID geprüft und ggf. angepasst werden.
|
||||||
|
|
||||||
|
## Was im Projekt erledigt werden muss
|
||||||
|
|
||||||
|
### 1. Vite-Variablen setzen
|
||||||
|
|
||||||
|
In der Projekt-`.env` die beiden Werte ergänzen:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_ADSENSE_CLIENT=ca-pub-1234567890123456
|
||||||
|
VITE_ADSENSE_HEADER_SLOT=1234567890
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis:
|
||||||
|
|
||||||
|
- `VITE_...` ist notwendig, damit die Werte im Client verfügbar sind
|
||||||
|
- ohne diese Werte bleibt der Banner automatisch unsichtbar
|
||||||
|
|
||||||
|
### 2. Frontend neu bauen
|
||||||
|
|
||||||
|
Nach Änderung der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach wie bisher den Build nach `docroot/dist` deployen.
|
||||||
|
|
||||||
|
### 3. Server/Deployment aktualisieren
|
||||||
|
|
||||||
|
Je nach Deploy-Prozess:
|
||||||
|
|
||||||
|
1. neuen Client-Build deployen
|
||||||
|
2. prüfen, dass `docroot/dist` aktuell ist
|
||||||
|
3. Service neu starten oder Deployment neu laden
|
||||||
|
|
||||||
|
## Prüfung nach dem Deploy
|
||||||
|
|
||||||
|
### Technisch
|
||||||
|
|
||||||
|
Prüfen:
|
||||||
|
|
||||||
|
- ist im HTML ein AdSense-Script geladen?
|
||||||
|
- erscheint im Header ein reservierter Anzeigenbereich?
|
||||||
|
- gibt es Fehler in der Browser-Konsole?
|
||||||
|
|
||||||
|
### Extern
|
||||||
|
|
||||||
|
Prüfen:
|
||||||
|
|
||||||
|
- `https://ypchat.net/ads.txt` ist erreichbar
|
||||||
|
- AdSense zeigt keinen `ads.txt`-Fehler mehr
|
||||||
|
- die Website ist in AdSense als bereit/freigegeben markiert
|
||||||
|
|
||||||
|
## Wichtige Hinweise
|
||||||
|
|
||||||
|
- Im lokalen Development erscheinen AdSense-Anzeigen oft nicht sinnvoll oder gar nicht.
|
||||||
|
- Nach dem ersten Einbau kann es dauern, bis Google echte Anzeigen ausliefert.
|
||||||
|
- Wenn Header-Anzeigen die UX zu stark stören, sollte der Banner auf Mobile ausgeblendet bleiben. Das ist im Code bereits berücksichtigt.
|
||||||
|
- Für Consent-/CMP-Themen kann je nach Land eine zusätzliche Einwilligungslösung nötig sein. Das ist aktuell nicht Teil dieser Implementierung.
|
||||||
|
|
||||||
|
## Kurz-Checkliste
|
||||||
|
|
||||||
|
1. In AdSense Website hinzufügen und freigeben lassen.
|
||||||
|
2. Header-Display-Ad-Unit anlegen.
|
||||||
|
3. Publisher-ID und Slot-ID notieren.
|
||||||
|
4. `docroot/ads.txt` auf korrekten Google-Eintrag prüfen.
|
||||||
|
5. `.env` mit `VITE_ADSENSE_CLIENT` und `VITE_ADSENSE_HEADER_SLOT` ergänzen.
|
||||||
|
6. Client neu bauen.
|
||||||
|
7. Deployen.
|
||||||
|
8. Live prüfen, ob `ads.txt` und Banner korrekt ausgeliefert werden.
|
||||||
353
DESIGN-KONZEPT.md
Normal file
353
DESIGN-KONZEPT.md
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
e# Design-Konzept: Modernisierung SingleChat
|
||||||
|
|
||||||
|
## Zielbild
|
||||||
|
|
||||||
|
SingleChat soll moderner, ruhiger und effizienter wirken, ohne seinen funktionalen Charakter zu verlieren. Die Oberfläche bleibt kompakt und schnell erfassbar, bekommt aber:
|
||||||
|
|
||||||
|
- eine konsistentere Farbwelt
|
||||||
|
- dezentere Rundungen
|
||||||
|
- klarere Hierarchien
|
||||||
|
- bessere mobile Nutzbarkeit
|
||||||
|
- mehr optische Ruhe bei gleicher Informationsdichte
|
||||||
|
|
||||||
|
Das Ziel ist keine komplette Neugestaltung, sondern ein kontrolliertes Redesign mit klarer Wiedererkennbarkeit.
|
||||||
|
|
||||||
|
## Beobachtungen im aktuellen UI
|
||||||
|
|
||||||
|
- Das Hauptgrün ist sehr dominant und wird auf vielen Flächen vollflächig eingesetzt.
|
||||||
|
- Navigation, Userliste und Chat konkurrieren visuell stark miteinander.
|
||||||
|
- Abstände und Höhen sind teilweise grob, dadurch wirkt die Oberfläche weniger präzise und nicht platzsparend.
|
||||||
|
- Farben codieren Geschlechter stark, aber ohne neutrales Basissystem um diese Akzentfarben herum.
|
||||||
|
- Mobile Nutzung ist nur eingeschränkt vorbereitet, weil die Desktop-Struktur sehr starr ist.
|
||||||
|
|
||||||
|
## Design-Prinzipien
|
||||||
|
|
||||||
|
- Kompakt vor luftig: wenig vertikale Höhe verschwenden.
|
||||||
|
- Neutraler Grundaufbau, Akzentfarbe nur gezielt einsetzen.
|
||||||
|
- Rundungen ja, aber klein bis mittel: modern, nicht verspielt.
|
||||||
|
- Hohe Kontraste für Lesbarkeit, aber weichere Flächenkontraste.
|
||||||
|
- Eine saubere visuelle Hierarchie: App-Rahmen, Navigation, Liste, Chat, Eingabe.
|
||||||
|
- Responsive first ab Tablet abwärts, ohne Desktop-Stärke zu verlieren.
|
||||||
|
|
||||||
|
## Visuelle Richtung
|
||||||
|
|
||||||
|
### Grundcharakter
|
||||||
|
|
||||||
|
Die App soll wie ein modernes, nüchternes Messaging-Tool wirken:
|
||||||
|
|
||||||
|
- heller, neutraler Grundton
|
||||||
|
- gedämpftes Grün als Markenfarbe
|
||||||
|
- weiche Grauabstufungen für Flächen und Grenzen
|
||||||
|
- gezielte Statusfarben statt bunter Dauerflächen
|
||||||
|
|
||||||
|
### Farbstrategie
|
||||||
|
|
||||||
|
Die bisherige grüne Identität bleibt erhalten, wird aber deutlich verfeinert.
|
||||||
|
|
||||||
|
#### Primärpalette
|
||||||
|
|
||||||
|
- `Primary 700`: `#245c3a`
|
||||||
|
- `Primary 600`: `#2f6f46`
|
||||||
|
- `Primary 500`: `#3d8654`
|
||||||
|
- `Primary 100`: `#e7f1ea`
|
||||||
|
|
||||||
|
#### Neutrale Flächen
|
||||||
|
|
||||||
|
- `Bg App`: `#f4f6f5`
|
||||||
|
- `Bg Panel`: `#ffffff`
|
||||||
|
- `Bg Subtle`: `#eef2ef`
|
||||||
|
- `Border`: `#d7dfd9`
|
||||||
|
- `Text Strong`: `#18201b`
|
||||||
|
- `Text Muted`: `#5f6b63`
|
||||||
|
|
||||||
|
#### Status-/Akzentfarben
|
||||||
|
|
||||||
|
Diese Farben nur als Marker, Badge oder kleine Flächen einsetzen, nicht mehr als große Vollflächen:
|
||||||
|
|
||||||
|
- Info/aktiv: `#3f7cac`
|
||||||
|
- Erfolg: `#3d8654`
|
||||||
|
- Warnung: `#c78a2c`
|
||||||
|
- Fehler: `#c55252`
|
||||||
|
|
||||||
|
#### Geschlechterkennzeichnung
|
||||||
|
|
||||||
|
Die Geschlechterfarben sollten erhalten bleiben, aber stark abgeschwächt werden:
|
||||||
|
|
||||||
|
- nicht als Vollton-Hintergrund der kompletten User-Zeile
|
||||||
|
- stattdessen als linke Farbleiste, Punktindikator oder Badge
|
||||||
|
- Text bleibt auf neutralem Hintergrund
|
||||||
|
|
||||||
|
Dadurch bleibt die Kodierung sichtbar, ohne die Lesbarkeit und Ruhe zu stören.
|
||||||
|
|
||||||
|
## Formensprache
|
||||||
|
|
||||||
|
### Rundungen
|
||||||
|
|
||||||
|
- Panels: `10px`
|
||||||
|
- Inputs/Buttons: `8px`
|
||||||
|
- Kleine Tags/Badges: `999px`
|
||||||
|
- Message-Bubbles: `10px`
|
||||||
|
|
||||||
|
Damit wirkt die App zeitgemäß, bleibt aber sachlich.
|
||||||
|
|
||||||
|
### Schatten und Linien
|
||||||
|
|
||||||
|
- Statt starker Schatten: feine Konturen
|
||||||
|
- Schatten nur für Layer-Wechsel, z. B. mobiles Panel oder Bild-Modal
|
||||||
|
- Standardgrenze: `1px solid #d7dfd9`
|
||||||
|
|
||||||
|
## Typografie
|
||||||
|
|
||||||
|
Die vorhandene `Noto Sans`-Basis ist sinnvoll, vor allem wegen der Sprachabdeckung. Sie sollte beibehalten werden.
|
||||||
|
|
||||||
|
Empfohlene Hierarchie:
|
||||||
|
|
||||||
|
- App-Titel: `20px / 600`
|
||||||
|
- Bereichstitel: `16px / 600`
|
||||||
|
- Standardtext: `14px / 400`
|
||||||
|
- Meta-Text: `12px / 500`
|
||||||
|
- Buttons/Navigation: `13px / 600`
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- geringere Zeilenhöhen in Steuerbereichen
|
||||||
|
- mehr Gewichtsunterschied statt mehr Schriftgröße
|
||||||
|
|
||||||
|
## Layout-Konzept
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
|
||||||
|
Empfohlene Struktur:
|
||||||
|
|
||||||
|
- obere App-Bar mit Branding und Status
|
||||||
|
- darunter kompakte Aktionsleiste
|
||||||
|
- links Userliste
|
||||||
|
- rechts Hauptbereich mit Chat/Header/Input
|
||||||
|
|
||||||
|
#### Größen
|
||||||
|
|
||||||
|
- Header: `48px`
|
||||||
|
- Aktionsleiste: `40px`
|
||||||
|
- Userliste: `260px` Standardbreite
|
||||||
|
- Chat-Header: `52px`
|
||||||
|
- Eingabebereich: `56px` bis `64px`
|
||||||
|
|
||||||
|
Die vertikale Verdichtung ist wichtig, damit mehr Chat-Inhalt sichtbar bleibt.
|
||||||
|
|
||||||
|
### Tablet
|
||||||
|
|
||||||
|
- Userliste auf `220px` reduzieren
|
||||||
|
- Menüeinträge enger setzen
|
||||||
|
- Meta-Informationen im Chat-Header stärker verdichten
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
|
||||||
|
Die App sollte auf kleineren Breiten nicht dreispaltig bleiben.
|
||||||
|
|
||||||
|
Stattdessen:
|
||||||
|
|
||||||
|
- Userliste als einblendbares Off-Canvas-Panel
|
||||||
|
- Hauptnavigation horizontal scrollbar oder als Icon/Text-Leiste
|
||||||
|
- Chatbereich füllt die Breite vollständig
|
||||||
|
- Chat-Header mit Nutzername in einer Zeile, Meta in zweiter kleiner Zeile
|
||||||
|
- Eingabebereich sticky am unteren Rand
|
||||||
|
|
||||||
|
## Komponenten-Konzept
|
||||||
|
|
||||||
|
### 1. Header
|
||||||
|
|
||||||
|
Aktuell sehr schlicht. Neu:
|
||||||
|
|
||||||
|
- weißes oder leicht getöntes Panel
|
||||||
|
- kleineres, präziseres Branding
|
||||||
|
- optional rechts Session-/Statusinformationen
|
||||||
|
- klare Unterkante mit feiner Border statt harter Farbfläche
|
||||||
|
|
||||||
|
### 2. Menüleiste
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- weniger laut
|
||||||
|
- kompakter
|
||||||
|
- besser scannbar
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- Buttons als sekundäre Tabs oder Segment-Buttons
|
||||||
|
- aktiver Punkt über Hintergrundtönung statt kräftiger Vollfarbe
|
||||||
|
- Ungelesen-Zähler als Badge
|
||||||
|
- Timeout und aktiver Chat als Meta-Info statt als dominante Blöcke
|
||||||
|
|
||||||
|
### 3. Userliste
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- dichter, moderner, besser filterbar wirkend
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- Zeilenhöhe ca. `36px` bis `40px`
|
||||||
|
- Flagge kleiner und sauber ausgerichtet
|
||||||
|
- Username links, Alter/Geschlecht als Meta rechts oder in zweiter reduzierter Textspur
|
||||||
|
- Geschlecht über Badge/Farbmarker statt komplette Hintergrundfarbe
|
||||||
|
- Hover nur leicht getönt
|
||||||
|
- aktive Auswahl klar, aber nicht grell
|
||||||
|
|
||||||
|
### 4. Chat-Header
|
||||||
|
|
||||||
|
Aktuell stark farbig nach Geschlecht. Neu:
|
||||||
|
|
||||||
|
- neutraler Header mit kleinem Farbakzent
|
||||||
|
- Name prominent, Meta-Infos sekundär
|
||||||
|
- optional Statuspunkt oder Marker links
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
- linke 4px-Akzentleiste nach Geschlecht
|
||||||
|
- weißer Hintergrund
|
||||||
|
- Name dunkel
|
||||||
|
- Alter/Land in `Text Muted`
|
||||||
|
|
||||||
|
### 5. Nachrichtenbereich
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- besser lesbare Nachrichten
|
||||||
|
- klarere Trennung zwischen eigener und fremder Nachricht
|
||||||
|
- trotzdem kompakt
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- Nachrichten als Bubble mit leichter Tönung
|
||||||
|
- eigene Nachrichten leicht grünlich-neutral
|
||||||
|
- fremde Nachrichten weiß
|
||||||
|
- Username klein, aber klar erkennbar
|
||||||
|
- Timestamp nur per Hover oder sehr subtil
|
||||||
|
- weniger Rahmen, mehr Fläche und Abstandssystem
|
||||||
|
|
||||||
|
### 6. Eingabebereich
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- platzsparend
|
||||||
|
- mobil belastbar
|
||||||
|
- moderne Interaktion
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- Eingabefeld als primäre Fläche
|
||||||
|
- Senden-Button kompakt
|
||||||
|
- Smiley/Bild als Icon-Buttons mit identischer Größe
|
||||||
|
- obere Border statt massiver grauer Box
|
||||||
|
- Smiley-Leiste als kleines Popover statt großer Block
|
||||||
|
|
||||||
|
### 7. Login-Bereich
|
||||||
|
|
||||||
|
Ziel:
|
||||||
|
|
||||||
|
- freundlicher erster Eindruck
|
||||||
|
- kompakteres Formular
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- Formular in Card-Layout
|
||||||
|
- zweispaltig auf breiteren Screens, einspaltig mobil
|
||||||
|
- Labels kleiner, Felder konsistent hoch
|
||||||
|
- Willkommenstext visuell vom Formular getrennt
|
||||||
|
|
||||||
|
### 8. Tabellen und Systemmeldungen
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- Systemmeldungen mit getönter Fläche und weicher Border
|
||||||
|
- Befehlstabellen mit sticky Header beibehalten
|
||||||
|
- Tabellen kompakter paddings, aber bessere Zeilentrennung
|
||||||
|
|
||||||
|
## Spacing-System
|
||||||
|
|
||||||
|
Ein festes Raster reduziert visuelle Unruhe.
|
||||||
|
|
||||||
|
Empfehlung:
|
||||||
|
|
||||||
|
- `4px`
|
||||||
|
- `8px`
|
||||||
|
- `12px`
|
||||||
|
- `16px`
|
||||||
|
- `24px`
|
||||||
|
|
||||||
|
Regel:
|
||||||
|
|
||||||
|
- Innenabstände in Controls meist `8px` oder `12px`
|
||||||
|
- Bereichsabstände meist `12px` oder `16px`
|
||||||
|
- keine beliebigen Einzelwerte mehr
|
||||||
|
|
||||||
|
## Responsive-Regeln
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
- `>= 1200px`: voller Desktop
|
||||||
|
- `< 1200px`: kompakter Desktop/Tablet
|
||||||
|
- `< 900px`: Userliste schmaler, Navigation enger
|
||||||
|
- `< 720px`: Userliste als Overlay/Drawer
|
||||||
|
- `< 560px`: Aktionsleiste stark verdichten, nur wichtigste Texte sichtbar
|
||||||
|
|
||||||
|
### Mobile Prioritäten
|
||||||
|
|
||||||
|
- aktive Konversation hat Vorrang vor Nebenspalten
|
||||||
|
- Bedienelemente müssen einhändig erreichbar bleiben
|
||||||
|
- keine horizontalen Layoutbrüche
|
||||||
|
- Chatinput immer sichtbar
|
||||||
|
|
||||||
|
## Interaktionsdetails
|
||||||
|
|
||||||
|
- Hover-Effekte sehr leicht halten
|
||||||
|
- Fokus-Zustände klar sichtbar, farblich aus Primärpalette
|
||||||
|
- Animationen kurz und funktional, z. B. `120ms` bis `180ms`
|
||||||
|
- Keine permanente Pulsen-Animation für Inbox mehr; Badge oder sanfter Highlight-Zustand reicht meist aus
|
||||||
|
|
||||||
|
## Technische Empfehlung für die Umsetzung
|
||||||
|
|
||||||
|
### Design Tokens in `client/src/style.css`
|
||||||
|
|
||||||
|
Zuerst zentrale CSS-Variablen definieren:
|
||||||
|
|
||||||
|
- Farben
|
||||||
|
- Radius
|
||||||
|
- Shadow
|
||||||
|
- Border
|
||||||
|
- Spacing
|
||||||
|
- Höhen wichtiger UI-Bausteine
|
||||||
|
|
||||||
|
Beispielhafte Token-Gruppen:
|
||||||
|
|
||||||
|
- `--color-bg-app`
|
||||||
|
- `--color-bg-panel`
|
||||||
|
- `--color-border`
|
||||||
|
- `--color-primary`
|
||||||
|
- `--radius-md`
|
||||||
|
- `--space-2`
|
||||||
|
- `--header-height`
|
||||||
|
|
||||||
|
### Danach komponentenweise umbauen
|
||||||
|
|
||||||
|
Sinnvolle Reihenfolge:
|
||||||
|
|
||||||
|
1. globale Tokens und App-Hintergrund
|
||||||
|
2. Header und Menüleiste
|
||||||
|
3. Userliste
|
||||||
|
4. Chat-Header und Nachrichten
|
||||||
|
5. Eingabebereich
|
||||||
|
6. Login und Nebenansichten
|
||||||
|
7. Responsive Verhalten
|
||||||
|
|
||||||
|
## Nicht-Ziele
|
||||||
|
|
||||||
|
- kein komplettes Rebranding
|
||||||
|
- keine starke Glasoptik
|
||||||
|
- keine großen Rundungen
|
||||||
|
- keine farblich überladene Gender-Codierung
|
||||||
|
- keine luftige SaaS-Optik mit verschwendetem Platz
|
||||||
|
|
||||||
|
## Ergebnisbild in einem Satz
|
||||||
|
|
||||||
|
SingleChat soll nach der Überarbeitung wie ein kompaktes, modernes Chat-Tool wirken: ruhig, klar strukturiert, responsiv, markentreu grün und deutlich hochwertiger, ohne unnötig anders auszusehen.
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
<title>SingleChat - Chat, Single-Chat und Bildaustausch</title>
|
<title>SingleChat - Chat, Single-Chat und Bildaustausch</title>
|
||||||
<meta name="description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch. Chatte mit Menschen aus aller Welt, finde neue Kontakte und teile Erinnerungen sicher und komfortabel.">
|
<meta name="description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch. Chatte mit Menschen aus aller Welt, finde neue Kontakte und teile Erinnerungen sicher und komfortabel.">
|
||||||
<meta name="keywords" content="Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community">
|
<meta name="keywords" content="Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community">
|
||||||
<meta name="robots" content="index, follow">
|
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1">
|
||||||
|
<meta name="author" content="SingleChat">
|
||||||
|
<meta name="theme-color" content="#2f6f46">
|
||||||
|
|
||||||
<!-- Open Graph Tags -->
|
<!-- Open Graph Tags -->
|
||||||
<meta property="og:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
<meta property="og:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
||||||
@@ -14,9 +16,11 @@
|
|||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://ypchat.net/">
|
<meta property="og:url" content="https://ypchat.net/">
|
||||||
<meta property="og:image" content="https://ypchat.net/static/favicon.png">
|
<meta property="og:image" content="https://ypchat.net/static/favicon.png">
|
||||||
|
<meta property="og:site_name" content="SingleChat">
|
||||||
|
<meta property="og:locale" content="de_DE">
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
<meta name="twitter:card" content="summary">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
<meta name="twitter:title" content="SingleChat - Chat, Single-Chat und Bildaustausch">
|
||||||
<meta name="twitter:description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.">
|
<meta name="twitter:description" content="Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.">
|
||||||
<meta name="twitter:image" content="https://ypchat.net/static/favicon.png">
|
<meta name="twitter:image" content="https://ypchat.net/static/favicon.png">
|
||||||
@@ -26,10 +30,10 @@
|
|||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="/static/favicon.png">
|
<link rel="icon" type="image/png" href="/static/favicon.png">
|
||||||
|
<script type="application/ld+json" id="seo-json-ld">{"@context":"https://schema.org","@type":"WebSite","name":"SingleChat","url":"https://ypchat.net/","description":"Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.","inLanguage":"de-DE"}</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
1295
client/package-lock.json
generated
1295
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -18,8 +18,8 @@
|
|||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"terser": "^5.44.1",
|
"terser": "^5.44.1",
|
||||||
"vite": "^5.4.11"
|
"vite": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<div class="chat-input-container">
|
<div class="chat-input-container">
|
||||||
<input
|
<input
|
||||||
v-model="message"
|
v-model="message"
|
||||||
type="text"
|
:type="inputType"
|
||||||
:placeholder="$t('button_send')"
|
:placeholder="inputPlaceholder"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
<button @click="sendMessage">{{ $t('button_send') }}</button>
|
<button @click="sendMessage">{{ $t('button_send') }}</button>
|
||||||
@@ -17,7 +17,12 @@
|
|||||||
style="display: none"
|
style="display: none"
|
||||||
@change="handleImageUpload"
|
@change="handleImageUpload"
|
||||||
/>
|
/>
|
||||||
<button class="no-style" @click="$refs.fileInput.click()" :title="$t('tooltip_send_image')">
|
<button
|
||||||
|
class="no-style"
|
||||||
|
@click="$refs.fileInput.click()"
|
||||||
|
:title="$t('tooltip_send_image')"
|
||||||
|
:disabled="!hasConversation"
|
||||||
|
>
|
||||||
<img src="/image.png" alt="Image" />
|
<img src="/image.png" alt="Image" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -35,12 +40,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const showSmileys = ref(false);
|
const showSmileys = ref(false);
|
||||||
|
const hasConversation = computed(() => !!chatStore.currentConversation);
|
||||||
|
const isAwaitingUsername = computed(() => chatStore.awaitingLoginUsername);
|
||||||
|
const isAwaitingPassword = computed(() => chatStore.awaitingLoginPassword);
|
||||||
|
|
||||||
|
const inputPlaceholder = computed(() => {
|
||||||
|
if (isAwaitingUsername.value) {
|
||||||
|
return 'Admin-Username eingeben';
|
||||||
|
}
|
||||||
|
if (isAwaitingPassword.value) {
|
||||||
|
return 'Admin-Passwort eingeben';
|
||||||
|
}
|
||||||
|
return hasConversation.value
|
||||||
|
? 'Nachricht senden oder /Befehl eingeben'
|
||||||
|
: 'Nur /Befehle eingeben (z.B. /login, /stat help)';
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputType = computed(() => (isAwaitingPassword.value ? 'password' : 'text'));
|
||||||
|
|
||||||
// Smiley-Definitionen (wie im Original)
|
// Smiley-Definitionen (wie im Original)
|
||||||
const smileys = {
|
const smileys = {
|
||||||
@@ -70,9 +92,21 @@ function sendMessage() {
|
|||||||
const trimmed = message.value.trim();
|
const trimmed = message.value.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
const isCommand = trimmed.startsWith('/');
|
const isCommand = trimmed.startsWith('/');
|
||||||
if (!isCommand && !chatStore.currentConversation) return;
|
const canSendPlain =
|
||||||
|
hasConversation.value || isAwaitingUsername.value || isAwaitingPassword.value;
|
||||||
|
|
||||||
chatStore.sendMessage(chatStore.currentConversation, trimmed);
|
if (!isCommand && !canSendPlain) {
|
||||||
|
chatStore.errorMessage = 'Ohne aktive Konversation sind nur /Befehle erlaubt.';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (chatStore.errorMessage === 'Ohne aktive Konversation sind nur /Befehle erlaubt.') {
|
||||||
|
chatStore.errorMessage = null;
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppressLocal = isCommand || isAwaitingUsername.value || isAwaitingPassword.value;
|
||||||
|
chatStore.sendMessage(chatStore.currentConversation, trimmed, { suppressLocal });
|
||||||
message.value = '';
|
message.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,10 @@ function formatTime(timestamp) {
|
|||||||
.no-conversation {
|
.no-conversation {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #637067;
|
||||||
|
border: 1px dashed #d7dfd9;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
@@ -118,7 +121,7 @@ function formatTime(timestamp) {
|
|||||||
.chat-image {
|
.chat-image {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
display: block;
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -151,7 +154,8 @@ function formatTime(timestamp) {
|
|||||||
width: 80%;
|
width: 80%;
|
||||||
height: 80%;
|
height: 80%;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 14px;
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -166,7 +170,7 @@ function formatTime(timestamp) {
|
|||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 10px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
@@ -190,4 +194,3 @@ function formatTime(timestamp) {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
482
client/src/components/FeedbackPanel.vue
Normal file
482
client/src/components/FeedbackPanel.vue
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['feedback-panel-shell', { 'feedback-panel-embedded': embedded }]">
|
||||||
|
<section v-if="showHero" class="feedback-hero">
|
||||||
|
<p class="feedback-eyebrow">Oeffentliches Feedback</p>
|
||||||
|
<h2>Meinungen, Hinweise und Verbesserungsvorschlaege</h2>
|
||||||
|
<p>
|
||||||
|
Alle Kommentare sind oeffentlich sichtbar. Pflicht ist nur der Kommentar selbst, alle weiteren Angaben sind optional.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="feedback-layout">
|
||||||
|
<section class="feedback-form-panel">
|
||||||
|
<h3>Feedback senden</h3>
|
||||||
|
<form class="feedback-form" @submit.prevent="submitFeedback">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input v-model="form.name" type="text" maxlength="80">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Alter</span>
|
||||||
|
<input v-model="form.age" type="number" min="18" max="120">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Land</span>
|
||||||
|
<select v-model="form.country">
|
||||||
|
<option value="">{{ $t('label_country') }}</option>
|
||||||
|
<option v-for="(code, name) in countries" :key="code" :value="name">
|
||||||
|
{{ name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Geschlecht</span>
|
||||||
|
<select v-model="form.gender">
|
||||||
|
<option value="">{{ $t('label_gender') }}</option>
|
||||||
|
<option value="F">{{ $t('gender_female') }}</option>
|
||||||
|
<option value="M">{{ $t('gender_male') }}</option>
|
||||||
|
<option value="P">{{ $t('gender_pair') }}</option>
|
||||||
|
<option value="TF">{{ $t('gender_trans_mf') }}</option>
|
||||||
|
<option value="TM">{{ $t('gender_trans_fm') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="feedback-form-comment">
|
||||||
|
<span>Kommentar *</span>
|
||||||
|
<textarea v-model="form.comment" rows="7" required maxlength="4000"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit" :disabled="isSubmitting">
|
||||||
|
{{ isSubmitting ? 'Wird gesendet...' : 'Feedback absenden' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="submitMessage" class="feedback-success">{{ submitMessage }}</p>
|
||||||
|
<p v-if="submitError" class="feedback-error">{{ submitError }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="feedback-list-panel">
|
||||||
|
<div class="feedback-list-header">
|
||||||
|
<div>
|
||||||
|
<h3>Kommentare</h3>
|
||||||
|
<p>{{ feedbackItems.length }} Eintraege</p>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-admin">
|
||||||
|
<template v-if="adminStatus.authenticated">
|
||||||
|
<span class="feedback-admin-badge">Admin: {{ adminStatus.username }}</span>
|
||||||
|
<button type="button" class="feedback-admin-button" @click="logoutAdmin">Logout</button>
|
||||||
|
</template>
|
||||||
|
<form v-else class="feedback-admin-form" @submit.prevent="loginAdmin">
|
||||||
|
<input v-model="adminForm.username" type="text" placeholder="Admin-Name">
|
||||||
|
<input v-model="adminForm.password" type="password" placeholder="Passwort">
|
||||||
|
<button type="submit">Admin-Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="adminError" class="feedback-error">{{ adminError }}</p>
|
||||||
|
|
||||||
|
<div class="feedback-list">
|
||||||
|
<article v-for="item in feedbackItems" :key="item.id" class="feedback-item">
|
||||||
|
<header class="feedback-item-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ item.name || 'Anonym' }}</strong>
|
||||||
|
<span v-if="formatMeta(item)" class="feedback-item-meta">{{ formatMeta(item) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-item-actions">
|
||||||
|
<time :datetime="item.createdAt">{{ formatDate(item.createdAt) }}</time>
|
||||||
|
<button
|
||||||
|
v-if="adminStatus.authenticated"
|
||||||
|
type="button"
|
||||||
|
class="feedback-delete"
|
||||||
|
@click="deleteFeedback(item.id)"
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p>{{ item.comment }}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<p v-if="feedbackItems.length === 0" class="feedback-empty">
|
||||||
|
Noch kein Feedback vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import countryTranslations from '../i18n/countries.json';
|
||||||
|
import { readProfileCookie } from '../utils/profileCookie';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
showHero: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
embedded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { locale } = useI18n();
|
||||||
|
|
||||||
|
const feedbackItems = ref([]);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const submitMessage = ref('');
|
||||||
|
const submitError = ref('');
|
||||||
|
const adminError = ref('');
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
age: '',
|
||||||
|
country: '',
|
||||||
|
gender: '',
|
||||||
|
comment: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const countriesRaw = ref({});
|
||||||
|
|
||||||
|
const countries = computed(() => {
|
||||||
|
const translated = {};
|
||||||
|
const translations = countryTranslations[locale.value] || countryTranslations.en || {};
|
||||||
|
|
||||||
|
for (const [englishName, code] of Object.entries(countriesRaw.value)) {
|
||||||
|
translated[translations[englishName] || englishName] = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = {};
|
||||||
|
Object.keys(translated)
|
||||||
|
.sort((a, b) => a.localeCompare(b, locale.value))
|
||||||
|
.forEach((key) => {
|
||||||
|
sorted[key] = translated[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminForm = ref({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminStatus = ref({
|
||||||
|
authenticated: false,
|
||||||
|
username: null
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadFeedback() {
|
||||||
|
const response = await axios.get('/api/feedback', { withCredentials: true });
|
||||||
|
feedbackItems.value = response.data.items || [];
|
||||||
|
adminStatus.value.authenticated = !!response.data.admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminStatus() {
|
||||||
|
const response = await axios.get('/api/feedback/admin-status', { withCredentials: true });
|
||||||
|
adminStatus.value = response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFeedback() {
|
||||||
|
isSubmitting.value = true;
|
||||||
|
submitMessage.value = '';
|
||||||
|
submitError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('/api/feedback', form.value, { withCredentials: true });
|
||||||
|
submitMessage.value = 'Danke. Dein Feedback wurde gespeichert.';
|
||||||
|
const profile = readProfileCookie();
|
||||||
|
form.value = {
|
||||||
|
name: profile?.nickname || '',
|
||||||
|
age: Number.isFinite(profile?.age) ? profile.age : '',
|
||||||
|
country: profile?.country || '',
|
||||||
|
gender: profile?.gender || '',
|
||||||
|
comment: ''
|
||||||
|
};
|
||||||
|
await loadFeedback();
|
||||||
|
} catch (error) {
|
||||||
|
submitError.value = error.response?.data?.error || 'Feedback konnte nicht gespeichert werden.';
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAdmin() {
|
||||||
|
adminError.value = '';
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/feedback/admin-login', adminForm.value, { withCredentials: true });
|
||||||
|
adminStatus.value = { authenticated: true, username: response.data.username };
|
||||||
|
adminForm.value.password = '';
|
||||||
|
} catch (error) {
|
||||||
|
adminError.value = error.response?.data?.error || 'Admin-Login fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAdmin() {
|
||||||
|
await axios.post('/api/feedback/admin-logout', {}, { withCredentials: true });
|
||||||
|
adminStatus.value = { authenticated: false, username: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFeedback(id) {
|
||||||
|
await axios.delete(`/api/feedback/${id}`, { withCredentials: true });
|
||||||
|
await loadFeedback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
return new Date(value).toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMeta(item) {
|
||||||
|
return [item.country, item.age, item.gender].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/countries');
|
||||||
|
countriesRaw.value = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Länderliste:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = readProfileCookie();
|
||||||
|
if (profile) {
|
||||||
|
form.value.name = profile.nickname || '';
|
||||||
|
form.value.age = Number.isFinite(profile.age) ? profile.age : '';
|
||||||
|
form.value.country = profile.country || '';
|
||||||
|
form.value.gender = profile.gender || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([loadFeedback(), loadAdminStatus()]);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feedback-panel-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-panel-embedded {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-hero {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(61, 134, 84, 0.18), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.95) 0%, rgba(243, 247, 244, 0.92) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #617067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-hero h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: #4e5b53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-layout {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 340px minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form-panel,
|
||||||
|
.feedback-list-panel {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 18px 40px rgba(31, 50, 39, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form-panel {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-list-panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form-panel h3,
|
||||||
|
.feedback-list-panel h3 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #536159;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form input,
|
||||||
|
.feedback-form textarea,
|
||||||
|
.feedback-admin-form input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #d3ddd5;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfdfb;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form button,
|
||||||
|
.feedback-admin-button,
|
||||||
|
.feedback-admin-form button,
|
||||||
|
.feedback-delete {
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #295f3d;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, #4a8d61 0%, #2c6240 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-list-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #66746b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7f1ea;
|
||||||
|
color: #245c3a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-form input {
|
||||||
|
width: 140px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-item {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #dce4de;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f9fbf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-item-header strong {
|
||||||
|
display: block;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-item-meta,
|
||||||
|
.feedback-item-actions time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #647168;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-delete {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-color: #b74848;
|
||||||
|
background: linear-gradient(180deg, #cd6161 0%, #a24040 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-item p,
|
||||||
|
.feedback-empty,
|
||||||
|
.feedback-success,
|
||||||
|
.feedback-error {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-success {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #245c3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #a24040;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.feedback-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-form {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-admin-form input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
client/src/components/HeaderAdBanner.vue
Normal file
80
client/src/components/HeaderAdBanner.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="isEnabled" class="header-ad-banner">
|
||||||
|
<ins
|
||||||
|
ref="adElement"
|
||||||
|
class="adsbygoogle"
|
||||||
|
style="display:block"
|
||||||
|
:data-ad-client="adClient"
|
||||||
|
:data-ad-slot="adSlot"
|
||||||
|
data-ad-format="auto"
|
||||||
|
data-full-width-responsive="true"
|
||||||
|
></ins>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const adElement = ref(null);
|
||||||
|
const adClient = import.meta.env.VITE_ADSENSE_CLIENT || '';
|
||||||
|
const adSlot = import.meta.env.VITE_ADSENSE_HEADER_SLOT || '';
|
||||||
|
const isEnabled = computed(() => Boolean(adClient && adSlot));
|
||||||
|
|
||||||
|
function ensureAdSenseScript() {
|
||||||
|
if (!isEnabled.value) return;
|
||||||
|
if (document.querySelector('script[data-adsense-loader="true"]')) return;
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.async = true;
|
||||||
|
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`;
|
||||||
|
script.crossOrigin = 'anonymous';
|
||||||
|
script.dataset.adsenseLoader = 'true';
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isEnabled.value) return;
|
||||||
|
|
||||||
|
ensureAdSenseScript();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Avoid duplicate initialization on remount.
|
||||||
|
if (adElement.value?.dataset.adsInitialized === 'true') return;
|
||||||
|
(window.adsbygoogle = window.adsbygoogle || []).push({});
|
||||||
|
if (adElement.value) {
|
||||||
|
adElement.value.dataset.adsInitialized = 'true';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('AdSense Banner konnte nicht initialisiert werden:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-ad-banner {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 728px;
|
||||||
|
margin: 0 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-ad-banner :deep(ins) {
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.header-ad-banner {
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 468px;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.header-ad-banner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="history-list">
|
<div class="history-list">
|
||||||
<div v-html="$t('history_title')"></div>
|
<div class="panel-header" v-html="$t('history_title')"></div>
|
||||||
|
|
||||||
<div v-if="chatStore.historyResults.length === 0">
|
<div v-if="chatStore.historyResults.length === 0" class="panel-empty">
|
||||||
<p>{{ $t('history_empty') }}</p>
|
<p>{{ $t('history_empty') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
class="history-item"
|
class="history-item"
|
||||||
@click="selectUser(item.userName)"
|
@click="selectUser(item.userName)"
|
||||||
>
|
>
|
||||||
{{ item.userName }}
|
<span class="panel-item-name">{{ item.userName }}</span>
|
||||||
<small v-if="item.lastMessage">
|
<small v-if="item.lastMessage" class="panel-item-meta">
|
||||||
- {{ formatTime(item.lastMessage.timestamp) }}
|
{{ formatTime(item.lastMessage.timestamp) }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,3 +35,32 @@ function formatTime(timestamp) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-empty {
|
||||||
|
color: #637067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-item-meta {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #637067;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="imprint-container">
|
<div class="imprint-container">
|
||||||
<a href="/partners">Partner</a>
|
<a href="/partners">Partner</a>
|
||||||
|
<a href="#" @click.prevent="showFeedback = true">Feedback</a>
|
||||||
<a href="#" @click.prevent="showImprint = true">Impressum</a>
|
<a href="#" @click.prevent="showImprint = true">Impressum</a>
|
||||||
|
|
||||||
|
<div v-if="showFeedback" class="imprint-dialog" @click.self="showFeedback = false">
|
||||||
|
<div class="feedback-dialog-content">
|
||||||
|
<button class="close-button" @click="showFeedback = false">×</button>
|
||||||
|
<FeedbackPanel :show-hero="false" :embedded="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="showImprint" class="imprint-dialog" @click.self="showImprint = false">
|
<div v-if="showImprint" class="imprint-dialog" @click.self="showImprint = false">
|
||||||
<div class="imprint-content">
|
<div class="imprint-content">
|
||||||
<button class="close-button" @click="showImprint = false">×</button>
|
<button class="close-button" @click="showImprint = false">×</button>
|
||||||
@@ -14,8 +22,10 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import FeedbackPanel from './FeedbackPanel.vue';
|
||||||
|
|
||||||
const showImprint = ref(false);
|
const showImprint = ref(false);
|
||||||
|
const showFeedback = ref(false);
|
||||||
|
|
||||||
const imprintText = `
|
const imprintText = `
|
||||||
<h1>Imprint</h1>
|
<h1>Imprint</h1>
|
||||||
@@ -55,30 +65,65 @@ Thanks for the flag icons to <a href="https://flagpedia.net">flagpedia.net</a>
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(18, 26, 21, 0.52);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1200;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imprint-content {
|
.imprint-content {
|
||||||
background: white;
|
background: #ffffff;
|
||||||
padding: 20px;
|
border: 1px solid #d7dfd9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px 20px 20px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
box-shadow: 0 24px 60px rgba(18, 26, 21, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-dialog-content {
|
||||||
|
width: min(1100px, 96vw);
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow: auto;
|
||||||
|
background: #f4f7f5;
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 24px 60px rgba(18, 26, 21, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
background: none;
|
width: 32px;
|
||||||
border: none;
|
height: 32px;
|
||||||
font-size: 24px;
|
background: #f6f9f7;
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
|
||||||
|
.imprint-content :deep(h1) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imprint-content :deep(p) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #344038;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imprint-content :deep(a) {
|
||||||
|
color: #245c3a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="inbox-list">
|
<div class="inbox-list">
|
||||||
<h2>{{ $t('menu_inbox') }}</h2>
|
<h2 class="panel-title">{{ $t('menu_inbox') }}</h2>
|
||||||
|
|
||||||
<div v-if="chatStore.inboxResults.length === 0">
|
<div v-if="chatStore.inboxResults.length === 0" class="panel-empty">
|
||||||
<p>Keine ungelesenen Nachrichten.</p>
|
<p>Keine ungelesenen Nachrichten.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
class="inbox-item"
|
class="inbox-item"
|
||||||
@click="selectUser(item.userName)"
|
@click="selectUser(item.userName)"
|
||||||
>
|
>
|
||||||
{{ item.userName }} ({{ item.unreadCount }} ungelesen)
|
<span class="panel-item-name">{{ item.userName }}</span>
|
||||||
|
<span class="panel-item-meta">{{ item.unreadCount }} ungelesen</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,3 +28,34 @@ function selectUser(userName) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-title {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-empty {
|
||||||
|
color: #637067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-item-meta {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #536159;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="login-content">
|
<div class="landing-login">
|
||||||
<form @submit.prevent="handleSubmit">
|
<section class="landing-login-intro">
|
||||||
<div class="form-row">
|
<p class="landing-login-eyebrow">SingleChat</p>
|
||||||
|
<h2>Direkt in den Chat</h2>
|
||||||
|
<p class="landing-login-copy">
|
||||||
|
Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.
|
||||||
|
</p>
|
||||||
|
<div class="landing-login-features">
|
||||||
|
<span>Weltweiter Chat</span>
|
||||||
|
<span>Bildaustausch</span>
|
||||||
|
<span>Kompakte Bedienung</span>
|
||||||
|
</div>
|
||||||
|
<div class="welcome-message" v-html="$t('welcome')"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="landing-login-card">
|
||||||
|
<div class="landing-login-card-header">
|
||||||
|
<h3>Profil starten</h3>
|
||||||
|
<p>Wenige Angaben genügen für den Einstieg.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="landing-login-fields" @submit.prevent="handleSubmit">
|
||||||
|
<div class="landing-form-row">
|
||||||
<label>{{ $t('label_nick') }}</label>
|
<label>{{ $t('label_nick') }}</label>
|
||||||
<input v-model="nickname" type="text" required minlength="3" />
|
<input v-model="nickname" type="text" required minlength="3" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="landing-form-row">
|
||||||
<label>{{ $t('label_gender') }}</label>
|
<label>{{ $t('label_gender') }}</label>
|
||||||
<select v-model="gender" required>
|
<select v-model="gender" required>
|
||||||
<option value="">{{ $t('label_gender') }}</option>
|
<option value="">{{ $t('label_gender') }}</option>
|
||||||
@@ -18,12 +38,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="landing-form-row">
|
||||||
<label>{{ $t('label_age') }}</label>
|
<label>{{ $t('label_age') }}</label>
|
||||||
<input v-model.number="age" type="number" required min="18" max="120" />
|
<input v-model.number="age" type="number" required min="18" max="120" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="landing-form-row">
|
||||||
<label>{{ $t('label_country') }}</label>
|
<label>{{ $t('label_country') }}</label>
|
||||||
<select v-model="country" required>
|
<select v-model="country" required>
|
||||||
<option value="">{{ $t('label_country') }}</option>
|
<option value="">{{ $t('label_country') }}</option>
|
||||||
@@ -33,21 +53,21 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="landing-form-row landing-form-row-submit">
|
||||||
<button type="submit">{{ $t('button_start_chat') }}</button>
|
<button type="submit">{{ $t('button_start_chat') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
<div class="welcome-message" v-html="$t('welcome')"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue';
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import countryTranslations from '../i18n/countries.json';
|
import countryTranslations from '../i18n/countries.json';
|
||||||
|
import { readProfileCookie, writeProfileCookie } from '../utils/profileCookie';
|
||||||
|
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
@@ -83,8 +103,41 @@ onMounted(async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Länderliste:', error);
|
console.error('Fehler beim Laden der Länderliste:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreProfileFromCookie();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch([nickname, gender, age, country], () => {
|
||||||
|
persistProfileToCookie();
|
||||||
|
});
|
||||||
|
|
||||||
|
function restoreProfileFromCookie() {
|
||||||
|
const profile = readProfileCookie();
|
||||||
|
if (!profile) return;
|
||||||
|
|
||||||
|
if (typeof profile.nickname === 'string') {
|
||||||
|
nickname.value = profile.nickname;
|
||||||
|
}
|
||||||
|
if (typeof profile.gender === 'string') {
|
||||||
|
gender.value = profile.gender;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(profile.age)) {
|
||||||
|
age.value = profile.age;
|
||||||
|
}
|
||||||
|
if (typeof profile.country === 'string') {
|
||||||
|
country.value = profile.country;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistProfileToCookie() {
|
||||||
|
writeProfileCookie({
|
||||||
|
nickname: nickname.value.trim(),
|
||||||
|
gender: gender.value,
|
||||||
|
age: Number(age.value) || 18,
|
||||||
|
country: country.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!nickname.value || nickname.value.trim().length < 3) {
|
if (!nickname.value || nickname.value.trim().length < 3) {
|
||||||
alert('Bitte gib einen gültigen Nicknamen ein (mindestens 3 Zeichen)');
|
alert('Bitte gib einen gültigen Nicknamen ein (mindestens 3 Zeichen)');
|
||||||
@@ -119,6 +172,176 @@ function handleSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chatStore.login(nickname.value.trim(), gender.value, age.value, englishCountryName);
|
chatStore.login(nickname.value.trim(), gender.value, age.value, englishCountryName);
|
||||||
|
persistProfileToCookie();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.landing-login {
|
||||||
|
width: 80%;
|
||||||
|
max-width: 1400px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: min(80%, 720px);
|
||||||
|
max-height: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-intro {
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid #cfe0d3;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(61, 134, 84, 0.28), transparent 32%),
|
||||||
|
linear-gradient(180deg, rgba(232, 243, 235, 0.98) 0%, rgba(244, 248, 245, 0.94) 100%);
|
||||||
|
box-shadow: 0 24px 60px rgba(31, 50, 39, 0.10);
|
||||||
|
min-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #496254;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-intro h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-copy {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #4f5d54;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-features {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-features span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #bfd5c4;
|
||||||
|
background: #e2efe5;
|
||||||
|
color: #245c3a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-card {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid #d4ddd6;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.99) 0%, rgba(249, 251, 249, 0.97) 100%);
|
||||||
|
box-shadow: 0 24px 60px rgba(31, 50, 39, 0.10);
|
||||||
|
min-height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-card-header {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-card-header h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-card-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: #637067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-form-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-form-row label {
|
||||||
|
min-width: 0;
|
||||||
|
color: #536159;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-form-row input,
|
||||||
|
.landing-form-row select {
|
||||||
|
width: 100%;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #d3ddd5;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fbfdfb;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-form-row input:focus,
|
||||||
|
.landing-form-row select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #8bb497;
|
||||||
|
box-shadow: 0 0 0 3px rgba(61, 134, 84, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-form-row button {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid #295f3d;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(180deg, #4a8d61 0%, #2c6240 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-form-row-submit {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.landing-login {
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: auto;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-intro,
|
||||||
|
.landing-login-card {
|
||||||
|
padding: 20px;
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.landing-login-intro h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,11 +6,18 @@
|
|||||||
{{ $t('menu_timeout_in', [formatTime(chatStore.remainingSecondsToTimeout)]) }}
|
{{ $t('menu_timeout_in', [formatTime(chatStore.remainingSecondsToTimeout)]) }}
|
||||||
</span>
|
</span>
|
||||||
<button @click="handleLeave">{{ $t('menu_leave') }}</button>
|
<button @click="handleLeave">{{ $t('menu_leave') }}</button>
|
||||||
<button @click="handleSearch">{{ $t('menu_search') }}</button>
|
<button @click="handleSearch" :class="{ 'is-active': chatStore.currentView === 'search' }">
|
||||||
<button @click="handleInbox" :class="{ 'has-unread': chatStore.unreadChatsCount > 0 }">
|
{{ $t('menu_search') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="handleInbox"
|
||||||
|
:class="{ 'has-unread': chatStore.unreadChatsCount > 0, 'is-active': chatStore.currentView === 'inbox' }"
|
||||||
|
>
|
||||||
{{ $t('menu_inbox') }}<span v-if="chatStore.unreadChatsCount > 0"> ({{ chatStore.unreadChatsCount }})</span>
|
{{ $t('menu_inbox') }}<span v-if="chatStore.unreadChatsCount > 0"> ({{ chatStore.unreadChatsCount }})</span>
|
||||||
</button>
|
</button>
|
||||||
<button @click="handleHistory">{{ $t('menu_history') }}</button>
|
<button @click="handleHistory" :class="{ 'is-active': chatStore.currentView === 'history' }">
|
||||||
|
{{ $t('menu_history') }}
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -42,4 +49,3 @@ function formatTime(seconds) {
|
|||||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="search-form">
|
<div class="search-form">
|
||||||
<div v-html="$t('search_title')"></div>
|
<div class="panel-header" v-html="$t('search_title')"></div>
|
||||||
|
|
||||||
<form @submit.prevent="handleSearch">
|
<form @submit.prevent="handleSearch">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -62,9 +62,13 @@
|
|||||||
v-if="user.isoCountryCode"
|
v-if="user.isoCountryCode"
|
||||||
:src="`/static/flags/${user.isoCountryCode}.png`"
|
:src="`/static/flags/${user.isoCountryCode}.png`"
|
||||||
:alt="user.country"
|
:alt="user.country"
|
||||||
style="width: 16px; height: 12px; margin-right: 5px;"
|
class="search-flag"
|
||||||
/>
|
/>
|
||||||
{{ user.userName }} ({{ user.age }}, {{ user.gender }}, {{ user.country }})
|
<span class="search-result-main">
|
||||||
|
<strong>{{ user.userName }}</strong>
|
||||||
|
<span>{{ user.country }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="search-result-meta">{{ user.age }} · {{ user.gender }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,6 +197,53 @@ function selectUser(userName) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.panel-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-flag {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-main strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-main span {
|
||||||
|
color: #637067;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #536159;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.form-row-age {
|
.form-row-age {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
@@ -219,6 +270,9 @@ function selectUser(userName) {
|
|||||||
|
|
||||||
:deep(.multiselect) {
|
:deep(.multiselect) {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.multiselect-input-wrapper) {
|
:deep(.multiselect-input-wrapper) {
|
||||||
@@ -255,7 +309,7 @@ function selectUser(userName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.multiselect-tag) {
|
:deep(.multiselect-tag) {
|
||||||
background: #429043;
|
background: #3d8654;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.25em 0.5em;
|
padding: 0.25em 0.5em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -280,7 +334,7 @@ function selectUser(userName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.multiselect-placeholder) {
|
:deep(.multiselect-placeholder) {
|
||||||
color: #999;
|
color: #8a948e;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.multiselect-single-label) {
|
:deep(.multiselect-single-label) {
|
||||||
@@ -314,7 +368,7 @@ function selectUser(userName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.multiselect-tags-search .multiselect-tag) {
|
:deep(.multiselect-tags-search .multiselect-tag) {
|
||||||
background: #429043;
|
background: #3d8654;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 0.25em 0.5em;
|
padding: 0.25em 0.5em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -333,7 +387,8 @@ function selectUser(userName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.multiselect.is-active) {
|
:deep(.multiselect.is-active) {
|
||||||
border-color: #429043;
|
border-color: #3d8654;
|
||||||
|
box-shadow: 0 0 0 3px rgba(61, 134, 84, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.multiselect.is-active .multiselect-tags) {
|
:deep(.multiselect.is-active .multiselect-tags) {
|
||||||
@@ -352,5 +407,3 @@ function selectUser(userName) {
|
|||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
glich werde
|
|
||||||
@@ -4,11 +4,15 @@
|
|||||||
{{ $t('logged_in_count', [chatStore.users.length]) }}
|
{{ $t('logged_in_count', [chatStore.users.length]) }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div v-if="chatStore.isLoggedIn">
|
<div v-if="chatStore.isLoggedIn" class="user-list-scroll">
|
||||||
<div
|
<button
|
||||||
v-for="user in chatStore.users"
|
v-for="user in chatStore.users"
|
||||||
:key="user.sessionId"
|
:key="user.sessionId"
|
||||||
:class="['user-item', `gender-${user.gender}`]"
|
:class="[
|
||||||
|
'user-item',
|
||||||
|
`gender-${user.gender}`,
|
||||||
|
{ 'is-active': chatStore.currentConversation === user.userName }
|
||||||
|
]"
|
||||||
@click="selectUser(user.userName)"
|
@click="selectUser(user.userName)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -17,8 +21,12 @@
|
|||||||
:alt="user.country"
|
:alt="user.country"
|
||||||
class="flag-icon"
|
class="flag-icon"
|
||||||
/>
|
/>
|
||||||
{{ user.userName }} ({{ user.age }}, {{ user.gender }})
|
<span class="user-main">
|
||||||
</div>
|
<span class="user-name">{{ user.userName }}</span>
|
||||||
|
<span class="user-country">{{ user.isoCountryCode || '' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="user-meta">{{ user.age }} · {{ user.gender }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,4 +42,3 @@ function selectUser(userName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,48 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import ChatView from '../views/ChatView.vue';
|
import ChatView from '../views/ChatView.vue';
|
||||||
import PartnersView from '../views/PartnersView.vue';
|
import PartnersView from '../views/PartnersView.vue';
|
||||||
|
import MockupView from '../views/MockupView.vue';
|
||||||
|
import FeedbackView from '../views/FeedbackView.vue';
|
||||||
|
|
||||||
|
const SITE_URL = 'https://ypchat.net';
|
||||||
|
const DEFAULT_IMAGE = `${SITE_URL}/static/favicon.png`;
|
||||||
|
|
||||||
|
const homeSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'SingleChat',
|
||||||
|
url: `${SITE_URL}/`,
|
||||||
|
description: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
||||||
|
inLanguage: 'de-DE'
|
||||||
|
};
|
||||||
|
|
||||||
|
const partnersSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: 'Partner - SingleChat',
|
||||||
|
url: `${SITE_URL}/partners`,
|
||||||
|
description: 'Unsere Partner und befreundete Seiten. Entdecke weitere interessante Angebote und Communities.',
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'SingleChat',
|
||||||
|
url: `${SITE_URL}/`
|
||||||
|
},
|
||||||
|
inLanguage: 'de-DE'
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedbackSchema = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: 'Feedback - SingleChat',
|
||||||
|
url: `${SITE_URL}/feedback`,
|
||||||
|
description: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'SingleChat',
|
||||||
|
url: `${SITE_URL}/`
|
||||||
|
},
|
||||||
|
inLanguage: 'de-DE'
|
||||||
|
};
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
@@ -10,7 +52,13 @@ const routes = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
title: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||||
description: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch. Chatte mit Menschen aus aller Welt, finde neue Kontakte und teile Erinnerungen sicher und komfortabel.',
|
description: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch. Chatte mit Menschen aus aller Welt, finde neue Kontakte und teile Erinnerungen sicher und komfortabel.',
|
||||||
keywords: 'Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community'
|
keywords: 'Chat, Single-Chat, Bildaustausch, Online-Chat, Singles, Kontakte, Community',
|
||||||
|
ogTitle: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||||
|
ogDescription: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
||||||
|
ogType: 'website',
|
||||||
|
image: DEFAULT_IMAGE,
|
||||||
|
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||||
|
schema: homeSchema
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -20,7 +68,45 @@ const routes = [
|
|||||||
meta: {
|
meta: {
|
||||||
title: 'Partner - SingleChat',
|
title: 'Partner - SingleChat',
|
||||||
description: 'Unsere Partner und befreundete Seiten. Entdecke weitere interessante Angebote und Communities.',
|
description: 'Unsere Partner und befreundete Seiten. Entdecke weitere interessante Angebote und Communities.',
|
||||||
keywords: 'Partner, Links, befreundete Seiten, Community'
|
keywords: 'Partner, Links, befreundete Seiten, Community',
|
||||||
|
ogTitle: 'Partner - SingleChat',
|
||||||
|
ogDescription: 'Unsere Partner und befreundete Seiten.',
|
||||||
|
ogType: 'website',
|
||||||
|
image: DEFAULT_IMAGE,
|
||||||
|
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||||
|
schema: partnersSchema
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/feedback',
|
||||||
|
name: 'feedback',
|
||||||
|
component: FeedbackView,
|
||||||
|
meta: {
|
||||||
|
title: 'Feedback - SingleChat',
|
||||||
|
description: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||||
|
keywords: 'SingleChat Feedback, Kommentare, Rueckmeldungen, Verbesserungsvorschlaege',
|
||||||
|
ogTitle: 'Feedback - SingleChat',
|
||||||
|
ogDescription: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||||
|
ogType: 'website',
|
||||||
|
image: DEFAULT_IMAGE,
|
||||||
|
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||||
|
schema: feedbackSchema
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/mockup-redesign',
|
||||||
|
name: 'mockup-redesign',
|
||||||
|
component: MockupView,
|
||||||
|
meta: {
|
||||||
|
title: 'Design Mockup - SingleChat',
|
||||||
|
description: 'Visuelle Vorschau des geplanten Design-Refreshs fuer SingleChat.',
|
||||||
|
keywords: 'SingleChat, Mockup, Design, Redesign, Vorschau',
|
||||||
|
ogTitle: 'Design Mockup - SingleChat',
|
||||||
|
ogDescription: 'Interne Vorschau des geplanten Design-Refreshs fuer SingleChat.',
|
||||||
|
ogType: 'website',
|
||||||
|
image: DEFAULT_IMAGE,
|
||||||
|
robots: 'noindex, nofollow, noarchive',
|
||||||
|
schema: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -30,15 +116,7 @@ const router = createRouter({
|
|||||||
routes
|
routes
|
||||||
});
|
});
|
||||||
|
|
||||||
// Meta-Tags dynamisch aktualisieren basierend auf Route
|
function updateMetaTag(name, content, attribute = 'name') {
|
||||||
router.beforeEach((to, from, next) => {
|
|
||||||
// Aktualisiere Title
|
|
||||||
if (to.meta.title) {
|
|
||||||
document.title = to.meta.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aktualisiere Meta-Tags
|
|
||||||
const updateMetaTag = (name, content, attribute = 'name') => {
|
|
||||||
let element = document.querySelector(`meta[${attribute}="${name}"]`);
|
let element = document.querySelector(`meta[${attribute}="${name}"]`);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
element = document.createElement('meta');
|
element = document.createElement('meta');
|
||||||
@@ -46,34 +124,66 @@ router.beforeEach((to, from, next) => {
|
|||||||
document.head.appendChild(element);
|
document.head.appendChild(element);
|
||||||
}
|
}
|
||||||
element.setAttribute('content', content);
|
element.setAttribute('content', content);
|
||||||
};
|
|
||||||
|
|
||||||
if (to.meta.description) {
|
|
||||||
updateMetaTag('description', to.meta.description);
|
|
||||||
updateMetaTag('og:description', to.meta.description, 'property');
|
|
||||||
updateMetaTag('twitter:description', to.meta.description);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.keywords) {
|
function updateLinkTag(rel, href) {
|
||||||
updateMetaTag('keywords', to.meta.keywords);
|
let element = document.querySelector(`link[rel="${rel}"]`);
|
||||||
|
if (!element) {
|
||||||
|
element = document.createElement('link');
|
||||||
|
element.setAttribute('rel', rel);
|
||||||
|
document.head.appendChild(element);
|
||||||
|
}
|
||||||
|
element.setAttribute('href', href);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aktualisiere Open Graph URL
|
function updateJsonLd(schema) {
|
||||||
const ogUrl = `https://ypchat.net${to.path}`;
|
let element = document.querySelector('#seo-json-ld');
|
||||||
updateMetaTag('og:url', ogUrl, 'property');
|
if (!element) {
|
||||||
updateMetaTag('canonical', ogUrl, 'rel');
|
element = document.createElement('script');
|
||||||
|
element.id = 'seo-json-ld';
|
||||||
// Aktualisiere Canonical Link
|
element.type = 'application/ld+json';
|
||||||
let canonicalLink = document.querySelector('link[rel="canonical"]');
|
document.head.appendChild(element);
|
||||||
if (!canonicalLink) {
|
|
||||||
canonicalLink = document.createElement('link');
|
|
||||||
canonicalLink.setAttribute('rel', 'canonical');
|
|
||||||
document.head.appendChild(canonicalLink);
|
|
||||||
}
|
}
|
||||||
canonicalLink.setAttribute('href', ogUrl);
|
|
||||||
|
element.textContent = schema ? JSON.stringify(schema) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const meta = to.meta || {};
|
||||||
|
const pageUrl = `${SITE_URL}${to.path}`;
|
||||||
|
const title = meta.title || 'SingleChat';
|
||||||
|
const description = meta.description || '';
|
||||||
|
const keywords = meta.keywords || '';
|
||||||
|
const ogTitle = meta.ogTitle || title;
|
||||||
|
const ogDescription = meta.ogDescription || description;
|
||||||
|
const ogType = meta.ogType || 'website';
|
||||||
|
const image = meta.image || DEFAULT_IMAGE;
|
||||||
|
const robots = meta.robots || 'index, follow';
|
||||||
|
|
||||||
|
document.title = title;
|
||||||
|
|
||||||
|
updateMetaTag('description', description);
|
||||||
|
updateMetaTag('keywords', keywords);
|
||||||
|
updateMetaTag('robots', robots);
|
||||||
|
updateMetaTag('theme-color', '#2f6f46');
|
||||||
|
|
||||||
|
updateMetaTag('og:title', ogTitle, 'property');
|
||||||
|
updateMetaTag('og:description', ogDescription, 'property');
|
||||||
|
updateMetaTag('og:type', ogType, 'property');
|
||||||
|
updateMetaTag('og:url', pageUrl, 'property');
|
||||||
|
updateMetaTag('og:image', image, 'property');
|
||||||
|
updateMetaTag('og:site_name', 'SingleChat', 'property');
|
||||||
|
updateMetaTag('og:locale', 'de_DE', 'property');
|
||||||
|
|
||||||
|
updateMetaTag('twitter:card', robots.startsWith('noindex') ? 'summary' : 'summary_large_image');
|
||||||
|
updateMetaTag('twitter:title', ogTitle);
|
||||||
|
updateMetaTag('twitter:description', ogDescription);
|
||||||
|
updateMetaTag('twitter:image', image);
|
||||||
|
|
||||||
|
updateLinkTag('canonical', pageUrl);
|
||||||
|
updateJsonLd(meta.schema || null);
|
||||||
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ref, computed } from 'vue';
|
|||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
||||||
export const useChatStore = defineStore('chat', () => {
|
export const useChatStore = defineStore('chat', () => {
|
||||||
|
const LOGOUT_MARKER_KEY = 'singlechat_logged_out';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
const userName = ref('');
|
const userName = ref('');
|
||||||
@@ -21,7 +23,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const historyResults = ref([]);
|
const historyResults = ref([]);
|
||||||
const unreadChatsCount = ref(0);
|
const unreadChatsCount = ref(0);
|
||||||
const errorMessage = ref(null);
|
const errorMessage = ref(null);
|
||||||
|
const commandTable = ref(null);
|
||||||
const remainingSecondsToTimeout = ref(1800);
|
const remainingSecondsToTimeout = ref(1800);
|
||||||
|
const awaitingLoginUsername = ref(false);
|
||||||
|
const awaitingLoginPassword = ref(false);
|
||||||
const searchData = ref({
|
const searchData = ref({
|
||||||
nameIncludes: '',
|
nameIncludes: '',
|
||||||
minAge: null,
|
minAge: null,
|
||||||
@@ -189,6 +194,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
handleWebSocketMessage({ type: 'commandResult', ...data });
|
handleWebSocketMessage({ type: 'commandResult', ...data });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socketInstance.on('commandTable', (data) => {
|
||||||
|
handleWebSocketMessage({ type: 'commandTable', ...data });
|
||||||
|
});
|
||||||
|
|
||||||
socketInstance.on('unreadChats', (data) => {
|
socketInstance.on('unreadChats', (data) => {
|
||||||
handleWebSocketMessage({ type: 'unreadChats', ...data });
|
handleWebSocketMessage({ type: 'unreadChats', ...data });
|
||||||
});
|
});
|
||||||
@@ -221,6 +230,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
country.value = data.user.country;
|
country.value = data.user.country;
|
||||||
isoCountryCode.value = data.user.isoCountryCode;
|
isoCountryCode.value = data.user.isoCountryCode;
|
||||||
sessionId.value = data.sessionId;
|
sessionId.value = data.sessionId;
|
||||||
|
// Wichtig: Beim frischen Login den Timeout-Timer starten
|
||||||
|
startTimeoutTimer();
|
||||||
break;
|
break;
|
||||||
case 'userList':
|
case 'userList':
|
||||||
users.value = data.users;
|
users.value = data.users;
|
||||||
@@ -293,23 +304,33 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
break;
|
break;
|
||||||
case 'commandResult': {
|
case 'commandResult': {
|
||||||
const lines = Array.isArray(data.lines) ? data.lines : [];
|
const lines = Array.isArray(data.lines) ? data.lines : [];
|
||||||
if (!currentConversation.value) {
|
const kind = data.kind || 'info';
|
||||||
|
|
||||||
|
if (kind === 'loginPromptUsername') {
|
||||||
|
awaitingLoginUsername.value = true;
|
||||||
|
awaitingLoginPassword.value = false;
|
||||||
|
} else if (kind === 'loginPromptPassword') {
|
||||||
|
awaitingLoginUsername.value = false;
|
||||||
|
awaitingLoginPassword.value = true;
|
||||||
|
} else if (kind === 'loginSuccess' || kind === 'loginError' || kind === 'loginAbort' || kind === 'loginLogout') {
|
||||||
|
awaitingLoginUsername.value = false;
|
||||||
|
awaitingLoginPassword.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command-Ausgaben immer global anzeigen, nicht im Chatverlauf.
|
||||||
errorMessage.value = lines.join(' | ');
|
errorMessage.value = lines.join(' | ');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
errorMessage.value = null;
|
errorMessage.value = null;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const timestamp = new Date().toISOString();
|
case 'commandTable': {
|
||||||
for (const line of lines) {
|
const title = data.title || 'Ausgabe';
|
||||||
messages.value.push({
|
const columns = Array.isArray(data.columns) ? data.columns : [];
|
||||||
from: 'System',
|
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||||
message: String(line),
|
commandTable.value = { title, columns, rows };
|
||||||
timestamp,
|
// Tabelle ist persistent; temporäre Fehlermeldung löschen
|
||||||
self: false,
|
errorMessage.value = null;
|
||||||
isImage: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'unreadChats':
|
case 'unreadChats':
|
||||||
@@ -327,6 +348,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login(userNameVal, genderVal, ageVal, countryVal) {
|
async function login(userNameVal, genderVal, ageVal, countryVal) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(LOGOUT_MARKER_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Logout-Marker konnte nicht entfernt werden:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Stelle sicher, dass Socket.IO verbunden ist
|
// Stelle sicher, dass Socket.IO verbunden ist
|
||||||
if (!socket.value || !socket.value.connected) {
|
if (!socket.value || !socket.value.connected) {
|
||||||
console.log('Socket.IO nicht verbunden, versuche Verbindung herzustellen...');
|
console.log('Socket.IO nicht verbunden, versuche Verbindung herzustellen...');
|
||||||
@@ -372,7 +399,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessage(toUserName, message) {
|
function sendMessage(toUserName, message, options = {}) {
|
||||||
if (!socket.value || !socket.value.connected) {
|
if (!socket.value || !socket.value.connected) {
|
||||||
console.error('Socket.IO nicht verbunden');
|
console.error('Socket.IO nicht verbunden');
|
||||||
return;
|
return;
|
||||||
@@ -387,8 +414,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
messageId
|
messageId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lokal hinzufügen (außer bei Commands, die serverseitig beantwortet werden)
|
const suppressLocal = !!options.suppressLocal || isCommand || awaitingLoginUsername.value || awaitingLoginPassword.value;
|
||||||
if (!isCommand) {
|
|
||||||
|
// Lokal hinzufügen (außer bei Commands oder sensiblen Eingaben wie Login)
|
||||||
|
if (!isCommand && !suppressLocal) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
from: userName.value,
|
from: userName.value,
|
||||||
message: trimmed,
|
message: trimmed,
|
||||||
@@ -538,6 +567,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
socket.value.emit('requestOpenConversations');
|
socket.value.emit('requestOpenConversations');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearCommandTable() {
|
||||||
|
commandTable.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
function setView(view) {
|
function setView(view) {
|
||||||
currentView.value = view;
|
currentView.value = view;
|
||||||
|
|
||||||
@@ -553,7 +586,22 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LOGOUT_MARKER_KEY, '1');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Logout-Marker konnte nicht gespeichert werden:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout-Request fehlgeschlagen:', error);
|
||||||
|
}
|
||||||
|
|
||||||
stopTimeoutTimer();
|
stopTimeoutTimer();
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
userName.value = '';
|
userName.value = '';
|
||||||
@@ -569,6 +617,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
inboxResults.value = [];
|
inboxResults.value = [];
|
||||||
historyResults.value = [];
|
historyResults.value = [];
|
||||||
|
commandTable.value = null;
|
||||||
searchData.value = {
|
searchData.value = {
|
||||||
nameIncludes: '',
|
nameIncludes: '',
|
||||||
minAge: null,
|
minAge: null,
|
||||||
@@ -616,6 +665,15 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
async function restoreSession() {
|
async function restoreSession() {
|
||||||
try {
|
try {
|
||||||
|
try {
|
||||||
|
if (window.localStorage.getItem(LOGOUT_MARKER_KEY) === '1') {
|
||||||
|
console.log('restoreSession: Automatische Wiederherstellung nach Logout unterdrueckt');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Logout-Marker konnte nicht gelesen werden:', error);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('restoreSession: Starte Session-Wiederherstellung...');
|
console.log('restoreSession: Starte Session-Wiederherstellung...');
|
||||||
const response = await fetch('/api/session', {
|
const response = await fetch('/api/session', {
|
||||||
credentials: 'include' // Wichtig für Cookies
|
credentials: 'include' // Wichtig für Cookies
|
||||||
@@ -684,7 +742,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
unreadChatsCount,
|
unreadChatsCount,
|
||||||
remainingSecondsToTimeout,
|
remainingSecondsToTimeout,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
commandTable,
|
||||||
searchData,
|
searchData,
|
||||||
|
awaitingLoginUsername,
|
||||||
|
awaitingLoginPassword,
|
||||||
// Computed
|
// Computed
|
||||||
currentConversationWith,
|
currentConversationWith,
|
||||||
// Actions
|
// Actions
|
||||||
@@ -696,9 +757,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
userSearch,
|
userSearch,
|
||||||
requestHistory,
|
requestHistory,
|
||||||
requestOpenConversations,
|
requestOpenConversations,
|
||||||
|
clearCommandTable,
|
||||||
setView,
|
setView,
|
||||||
logout,
|
logout,
|
||||||
restoreSession
|
restoreSession
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans&family=Noto+Sans+JP&family=Noto+Sans+SC&family=Noto+Sans+Thai&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600&family=Noto+Sans+JP&family=Noto+Sans+SC&family=Noto+Sans+Thai&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg-app: #f4f6f5;
|
||||||
|
--color-bg-shell: #edf2ee;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-surface-subtle: #f6f9f7;
|
||||||
|
--color-surface-muted: #eef3ef;
|
||||||
|
--color-border: #d7dfd9;
|
||||||
|
--color-border-strong: #c7d2ca;
|
||||||
|
--color-text-strong: #18201b;
|
||||||
|
--color-text: #2c362f;
|
||||||
|
--color-text-muted: #637067;
|
||||||
|
--color-primary-700: #245c3a;
|
||||||
|
--color-primary-600: #2f6f46;
|
||||||
|
--color-primary-500: #3d8654;
|
||||||
|
--color-primary-100: #e7f1ea;
|
||||||
|
--color-blue: #467bb2;
|
||||||
|
--color-pink: #d85f8c;
|
||||||
|
--color-gold: #c78a2c;
|
||||||
|
--color-purple: #8b60af;
|
||||||
|
--color-cyan: #5fa2bf;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--header-height: 58px;
|
||||||
|
--menu-height: 42px;
|
||||||
|
--footer-height: 34px;
|
||||||
|
--sidebar-width: 188px;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -6,11 +40,35 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #app {
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
font-family: 'Noto Sans', 'Noto Sans JP', 'Noto Sans SC', 'Noto Sans Thai', sans-serif;
|
font-family: 'Noto Sans', 'Noto Sans JP', 'Noto Sans SC', 'Noto Sans Thai', sans-serif;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-container {
|
.chat-container {
|
||||||
@@ -18,89 +76,148 @@ html, body, #app {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(61, 134, 84, 0.12), transparent 22%),
|
||||||
|
linear-gradient(180deg, var(--color-bg-app) 0%, var(--color-bg-shell) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: #ffffff;
|
min-height: var(--header-height);
|
||||||
color: #005100;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header > div,
|
|
||||||
.header > span {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
padding: 0 0.5em;
|
|
||||||
margin: 0;
|
|
||||||
display: inline-block;
|
|
||||||
color: #005100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
background-color: #2E7D32;
|
|
||||||
height: 2.6em;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 0.4em;
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
background: linear-gradient(180deg, rgba(208, 232, 216, 0.98) 0%, rgba(235, 245, 238, 0.94) 55%, rgba(247, 250, 248, 0.92) 100%);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(180deg, #3d8654 0%, #245c3a 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand-eyebrow {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #5a6a61;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-status-chip {
|
||||||
|
min-height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #cadecf;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: #445248;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
min-height: var(--menu-height);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 5px var(--space-3);
|
||||||
|
background: rgba(247, 250, 248, 0.92);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu > * {
|
.menu > * {
|
||||||
vertical-align: top;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu button {
|
.menu button {
|
||||||
background-color: #429043;
|
height: 30px;
|
||||||
color: #ffffff;
|
border: 1px solid transparent;
|
||||||
height: 2em;
|
border-radius: var(--radius-sm);
|
||||||
margin: 0.2em 0.4em;
|
padding: 0 12px;
|
||||||
cursor: pointer;
|
color: #425047;
|
||||||
border: none;
|
background: transparent;
|
||||||
padding: 0 0.5em;
|
font-size: 12px;
|
||||||
font-size: 14px;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu button:hover {
|
.menu button:hover {
|
||||||
background-color: #52a052;
|
background: rgba(231, 241, 234, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button.is-active {
|
||||||
|
background: linear-gradient(180deg, #dceee1 0%, #cfe6d6 100%);
|
||||||
|
border-color: #b8d4bf;
|
||||||
|
color: #1f4f32;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu button.has-unread {
|
.menu button.has-unread {
|
||||||
background-color: #ff6b6b;
|
border-color: #d7c0c0;
|
||||||
animation: pulse 2s infinite;
|
background: #fff1f1;
|
||||||
|
color: #9d4545;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu button.has-unread:hover {
|
.menu button.has-unread.is-active {
|
||||||
background-color: #ff5252;
|
background: linear-gradient(180deg, #f8e4e4 0%, #f1d2d2 100%);
|
||||||
|
border-color: #ddb7b7;
|
||||||
|
color: #8e3f3f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.menu-info-text {
|
||||||
0%, 100% {
|
display: inline-flex;
|
||||||
opacity: 1;
|
align-items: center;
|
||||||
}
|
min-height: 26px;
|
||||||
50% {
|
padding: 0 10px;
|
||||||
opacity: 0.8;
|
border-radius: 999px;
|
||||||
}
|
border: 1px solid var(--color-border);
|
||||||
}
|
background: var(--color-surface-subtle);
|
||||||
|
color: var(--color-text-muted);
|
||||||
.menu span {
|
font-size: 11px;
|
||||||
display: inline-block;
|
|
||||||
padding: 0.375em 0.4em;
|
|
||||||
color: #2E7D32;
|
|
||||||
border: 1px solid #fff;
|
|
||||||
background-color: lightgray;
|
|
||||||
margin: 0.1em 0.2em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu button span {
|
.menu button span {
|
||||||
color: #fff !important;
|
color: inherit;
|
||||||
background-color: transparent !important;
|
background: transparent;
|
||||||
border: none !important;
|
border: none;
|
||||||
padding: 0 !important;
|
padding: 0;
|
||||||
margin: 0 !important;
|
margin: 0;
|
||||||
display: inline !important;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal-box {
|
.horizontal-box {
|
||||||
@@ -110,52 +227,130 @@ html, body, #app {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.horizontal-box-app {
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.user-list {
|
.user-list {
|
||||||
width: 15em;
|
width: var(--sidebar-width);
|
||||||
background-color: lightgray;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.5em;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(247, 250, 247, 0.95) 0%, rgba(242, 246, 243, 0.92) 100%);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 18px 40px rgba(31, 50, 39, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-list h3 {
|
.user-list h3 {
|
||||||
margin-bottom: 0.5em;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item {
|
.user-item {
|
||||||
cursor: pointer;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.3em 0.5em;
|
min-height: 30px;
|
||||||
margin-bottom: 0.2em;
|
padding: 4px 6px;
|
||||||
|
border: 1px solid rgba(217, 225, 218, 0.8);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item:hover {
|
.user-item:hover {
|
||||||
background-color: #b0b0b0;
|
border-color: var(--color-border-strong);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item.is-active {
|
||||||
|
background: linear-gradient(180deg, rgba(236, 246, 239, 0.98) 0%, rgba(226, 239, 231, 0.96) 100%);
|
||||||
|
box-shadow: 0 8px 18px rgba(35, 54, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item.gender-M {
|
.user-item.gender-M {
|
||||||
background-color: #0066CC;
|
background-image: linear-gradient(90deg, rgba(70, 123, 178, 0.22), rgba(255, 255, 255, 0.68) 72%);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item.gender-F {
|
.user-item.gender-F {
|
||||||
background-color: #FF4081;
|
background-image: linear-gradient(90deg, rgba(216, 95, 140, 0.26), rgba(255, 255, 255, 0.68) 72%);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item.gender-P {
|
.user-item.gender-P {
|
||||||
background-color: #FFC107;
|
background-image: linear-gradient(90deg, rgba(199, 138, 44, 0.24), rgba(255, 255, 255, 0.68) 72%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item.gender-TM {
|
.user-item.gender-TM {
|
||||||
background-color: #90caf9;
|
background-image: linear-gradient(90deg, rgba(95, 162, 191, 0.22), rgba(255, 255, 255, 0.68) 72%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item.gender-TF {
|
.user-item.gender-TF {
|
||||||
background-color: #8E24AA;
|
background-image: linear-gradient(90deg, rgba(139, 96, 175, 0.22), rgba(255, 255, 255, 0.68) 72%);
|
||||||
color: #ffffff;
|
}
|
||||||
|
|
||||||
|
.flag-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-country {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #536159;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -165,91 +360,126 @@ html, body, #app {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(245, 248, 246, 0.94) 100%);
|
||||||
|
box-shadow: 0 18px 40px rgba(31, 50, 39, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-window {
|
.chat-window {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: 18px 20px;
|
||||||
background-color: white;
|
background: linear-gradient(180deg, #fbfdfb 0%, #f3f7f4 100%);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-box-format {
|
.output-box-format {
|
||||||
border: 1px solid #999;
|
max-width: 78%;
|
||||||
padding: 1px 6px;
|
border: 1px solid rgba(217, 226, 219, 0.9);
|
||||||
margin-bottom: 0.2em;
|
padding: 10px 12px;
|
||||||
border-radius: 3px;
|
margin-bottom: 10px;
|
||||||
line-height: 2em;
|
border-radius: var(--radius-md);
|
||||||
|
line-height: 1.45;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 250, 247, 0.96) 100%);
|
||||||
|
box-shadow: 0 10px 18px rgba(35, 54, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-box-format strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ouput-box-format-self {
|
.ouput-box-format-self {
|
||||||
background-color: #eaeaea;
|
margin-left: auto;
|
||||||
|
background: linear-gradient(180deg, #dff0e4 0%, #d2e7d9 100%);
|
||||||
|
border-color: #c8dccf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.output-box-format-other {
|
.output-box-format-other {
|
||||||
background-color: #fff;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 250, 247, 0.96) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container {
|
.chat-input-container {
|
||||||
padding: 10px;
|
padding: 12px 16px;
|
||||||
background-color: #f0f0f0;
|
background: linear-gradient(180deg, rgba(238, 245, 240, 0.92) 0%, rgba(247, 250, 248, 0.88) 100%);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container input {
|
.chat-input-container input {
|
||||||
flex: 1;
|
min-width: 0;
|
||||||
padding: 8px;
|
height: 40px;
|
||||||
border: 1px solid #ccc;
|
padding: 0 12px;
|
||||||
border-radius: 4px;
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(180deg, #fcfefc 0%, #f0f6f2 100%);
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container button {
|
.chat-input-container button {
|
||||||
padding: 8px 15px;
|
height: 40px;
|
||||||
background-color: #429043;
|
padding: 0 14px;
|
||||||
|
background: linear-gradient(180deg, #4a8d61 0%, #2c6240 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border: solid 1px #999;
|
border: 1px solid #295f3d;
|
||||||
border-radius: 0;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
min-height: 40px;
|
||||||
min-height: 2.3em;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container button:hover {
|
.chat-input-container button:hover {
|
||||||
background-color: #52a052;
|
filter: brightness(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container .no-style {
|
.chat-input-container .no-style {
|
||||||
border: none;
|
width: 40px;
|
||||||
background: none;
|
height: 40px !important;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: linear-gradient(180deg, #fdfefd 0%, #edf4ef 100%);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
display: inline-flex;
|
||||||
outline: none;
|
align-items: center;
|
||||||
cursor: pointer;
|
justify-content: center;
|
||||||
width: 31px !important;
|
}
|
||||||
height: 29px !important;
|
|
||||||
|
.chat-input-container .no-style:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-input-container .no-style > img {
|
.chat-input-container .no-style > img {
|
||||||
width: 31px;
|
width: 20px;
|
||||||
height: 31px;
|
height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imprint-container {
|
.imprint-container {
|
||||||
background-color: #f0f0f0;
|
min-height: var(--footer-height);
|
||||||
padding: 10px 20px;
|
padding: 0 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.imprint-container a {
|
.imprint-container a {
|
||||||
color: #005100;
|
color: #54635a;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin: 0 10px;
|
font-weight: 500;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.imprint-container a:hover {
|
.imprint-container a:hover {
|
||||||
@@ -258,8 +488,7 @@ html, body, #app {
|
|||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
max-width: 600px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-content {
|
.login-content {
|
||||||
@@ -267,6 +496,10 @@ html, body, #app {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
max-width: 40em;
|
max-width: 40em;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row {
|
.form-row {
|
||||||
@@ -278,134 +511,91 @@ html, body, #app {
|
|||||||
|
|
||||||
.form-row label {
|
.form-row label {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row input,
|
.form-row input,
|
||||||
.form-row select {
|
.form-row select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 5px;
|
height: 38px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-row button {
|
.form-row button {
|
||||||
padding: 8px 15px;
|
padding: 0 15px;
|
||||||
background-color: #429043;
|
background: linear-gradient(180deg, #4a8d61 0%, #2c6240 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border: solid 1px #999;
|
border: 1px solid #295f3d;
|
||||||
border-radius: 0;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
min-height: 2.3em;
|
min-height: 38px;
|
||||||
}
|
font-weight: 600;
|
||||||
|
|
||||||
.form-row button:hover {
|
|
||||||
background-color: #52a052;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-message {
|
.welcome-message {
|
||||||
margin-top: 20px;
|
padding: 16px;
|
||||||
padding: 20px;
|
background: var(--color-surface-subtle);
|
||||||
background-color: #f9f9f9;
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 4px;
|
border-radius: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
.search-form {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-form .form-row {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-results {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-item {
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result-item:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-form,
|
||||||
|
.search-results,
|
||||||
.inbox-list,
|
.inbox-list,
|
||||||
.history-list {
|
.history-list,
|
||||||
padding: 20px;
|
.partners-view {
|
||||||
|
padding: 18px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-result-item,
|
||||||
.inbox-item,
|
.inbox-item,
|
||||||
.history-item {
|
.history-item,
|
||||||
padding: 10px;
|
.partners-list li {
|
||||||
border-bottom: 1px solid #ddd;
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #e3e8e4;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover,
|
||||||
.inbox-item:hover,
|
.inbox-item:hover,
|
||||||
.history-item:hover {
|
.history-item:hover {
|
||||||
background-color: #f0f0f0;
|
background-color: #f4f7f4;
|
||||||
}
|
|
||||||
|
|
||||||
.partners-view {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link a {
|
.back-link a,
|
||||||
color: #429043;
|
.partners-list a {
|
||||||
|
color: var(--color-primary-700);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
}
|
|
||||||
|
|
||||||
.back-link a:hover {
|
|
||||||
color: #2E7D32;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.partners-list {
|
.partners-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partners-list li {
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.partners-list a {
|
|
||||||
color: #005100;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imprint-container a {
|
|
||||||
color: #005100;
|
|
||||||
text-decoration: none;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flag-icon {
|
|
||||||
margin: 0.25em 0.5em 0 0;
|
|
||||||
width: 16px;
|
|
||||||
height: 12px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.smiley-bar {
|
.smiley-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
max-width: 200px;
|
max-width: 220px;
|
||||||
bottom: 89px;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 16px;
|
||||||
font-size: 24pt;
|
font-size: 24pt;
|
||||||
right: 3px;
|
background-color: var(--color-surface);
|
||||||
background-color: #fff;
|
border: 1px solid var(--color-border);
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 0.3em;
|
padding: 0.3em;
|
||||||
border-radius: 4px;
|
border-radius: 12px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
box-shadow: 0 16px 30px rgba(31, 50, 39, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.smiley-item {
|
.smiley-item {
|
||||||
@@ -413,13 +603,53 @@ html, body, #app {
|
|||||||
padding: 0.2em;
|
padding: 0.2em;
|
||||||
margin: 0.1em;
|
margin: 0.1em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.smiley-item:hover {
|
.smiley-item:hover {
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f4f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.partners-list a:hover {
|
@media (max-width: 960px) {
|
||||||
text-decoration: underline;
|
.user-list {
|
||||||
|
width: 170px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-box-app {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.horizontal-box {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 150px;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-container {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-container button:not(.no-style) {
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
39
client/src/utils/profileCookie.js
Normal file
39
client/src/utils/profileCookie.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const PROFILE_COOKIE_NAME = 'singlechat_profile';
|
||||||
|
const PROFILE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
|
function readCookie(name) {
|
||||||
|
const prefix = `${name}=`;
|
||||||
|
const cookie = document.cookie
|
||||||
|
.split('; ')
|
||||||
|
.find((entry) => entry.startsWith(prefix));
|
||||||
|
|
||||||
|
return cookie ? cookie.slice(prefix.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readProfileCookie() {
|
||||||
|
const cookieValue = readCookie(PROFILE_COOKIE_NAME);
|
||||||
|
if (!cookieValue) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(decodeURIComponent(cookieValue));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Profil-Cookie konnte nicht gelesen werden:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeProfileCookie(profile) {
|
||||||
|
const normalizedProfile = {
|
||||||
|
nickname: typeof profile.nickname === 'string' ? profile.nickname.trim() : '',
|
||||||
|
gender: typeof profile.gender === 'string' ? profile.gender : '',
|
||||||
|
age: Number(profile.age) || 18,
|
||||||
|
country: typeof profile.country === 'string' ? profile.country : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
document.cookie = [
|
||||||
|
`${PROFILE_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(normalizedProfile))}`,
|
||||||
|
`Max-Age=${PROFILE_COOKIE_MAX_AGE}`,
|
||||||
|
'Path=/',
|
||||||
|
'SameSite=Lax'
|
||||||
|
].join('; ');
|
||||||
|
}
|
||||||
@@ -1,37 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>SingleChat</h1>
|
<div class="app-brand">
|
||||||
|
<span class="app-brand-mark">S</span>
|
||||||
|
<div class="app-brand-copy">
|
||||||
|
<span class="app-brand-eyebrow">SingleChat</span>
|
||||||
|
<h1>Chat</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HeaderAdBanner />
|
||||||
|
<div v-if="chatStore.isLoggedIn" class="header-status">
|
||||||
|
<span class="header-status-chip">{{ chatStore.userName }}</span>
|
||||||
|
<span v-if="chatStore.isoCountryCode" class="header-status-chip">{{ chatStore.isoCountryCode }}</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MenuBar />
|
<MenuBar v-if="chatStore.isLoggedIn" />
|
||||||
|
|
||||||
<div class="horizontal-box">
|
<div class="horizontal-box" :class="{ 'horizontal-box-login': !chatStore.isLoggedIn, 'horizontal-box-app': chatStore.isLoggedIn }">
|
||||||
<UserList />
|
<UserList v-if="chatStore.isLoggedIn" />
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div v-if="!chatStore.isLoggedIn" class="login-form">
|
<div v-if="!chatStore.isLoggedIn" class="login-screen">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="main-content-wrapper">
|
<div v-else class="main-content-wrapper">
|
||||||
|
<div v-if="chatStore.errorMessage" class="error-message">
|
||||||
|
{{ chatStore.errorMessage }}
|
||||||
|
</div>
|
||||||
|
<div v-if="chatStore.commandTable" class="command-table-container">
|
||||||
|
<div class="command-table-header">
|
||||||
|
<strong>{{ chatStore.commandTable.title }}</strong>
|
||||||
|
<button class="command-table-close" @click="chatStore.clearCommandTable()">Schließen</button>
|
||||||
|
</div>
|
||||||
|
<div class="command-table-scroll">
|
||||||
|
<table class="command-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="(column, idx) in chatStore.commandTable.columns" :key="`head-${idx}`">
|
||||||
|
{{ column }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, rowIdx) in chatStore.commandTable.rows" :key="`row-${rowIdx}`">
|
||||||
|
<td v-for="(cell, cellIdx) in row" :key="`cell-${rowIdx}-${cellIdx}`">
|
||||||
|
{{ cell }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<SearchView v-if="chatStore.currentView === 'search'" />
|
<SearchView v-if="chatStore.currentView === 'search'" />
|
||||||
<InboxView v-else-if="chatStore.currentView === 'inbox'" />
|
<InboxView v-else-if="chatStore.currentView === 'inbox'" />
|
||||||
<HistoryView v-else-if="chatStore.currentView === 'history'" />
|
<HistoryView v-else-if="chatStore.currentView === 'history'" />
|
||||||
<div v-else class="chat-content">
|
<div v-else class="chat-content">
|
||||||
<div v-if="chatStore.errorMessage" class="error-message">
|
<div v-if="chatStore.currentConversation && currentUserInfo" class="chat-header">
|
||||||
{{ chatStore.errorMessage }}
|
<span :class="['chat-header-accent', 'chat-header-accent-' + currentUserInfo.gender]"></span>
|
||||||
</div>
|
<div class="chat-header-main">
|
||||||
<div v-else-if="chatStore.currentConversation && currentUserInfo" :class="['chat-header', 'chat-header-gender-' + currentUserInfo.gender]">
|
<h2>{{ chatStore.currentConversation }}</h2>
|
||||||
<h2>{{ chatStore.currentConversation }} ({{ currentUserInfo.gender }})</h2>
|
|
||||||
<div class="chat-header-info">
|
<div class="chat-header-info">
|
||||||
<span v-if="currentUserInfo">{{ currentUserInfo.age }}</span>
|
|
||||||
<span v-if="currentUserInfo">{{ currentUserInfo.country }}</span>
|
<span v-if="currentUserInfo">{{ currentUserInfo.country }}</span>
|
||||||
|
<span v-if="currentUserInfo">{{ currentUserInfo.age }} · {{ currentUserInfo.gender }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChatWindow v-if="!chatStore.errorMessage" />
|
|
||||||
<ChatInput v-if="chatStore.currentConversation && !chatStore.errorMessage" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<ChatWindow />
|
||||||
|
</div>
|
||||||
|
<ChatInput />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +81,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, computed } from 'vue';
|
import { onMounted, computed } from 'vue';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import MenuBar from '../components/MenuBar.vue';
|
import MenuBar from '../components/MenuBar.vue';
|
||||||
import UserList from '../components/UserList.vue';
|
import UserList from '../components/UserList.vue';
|
||||||
import LoginForm from '../components/LoginForm.vue';
|
import LoginForm from '../components/LoginForm.vue';
|
||||||
@@ -53,26 +90,15 @@ import SearchView from '../components/SearchView.vue';
|
|||||||
import InboxView from '../components/InboxView.vue';
|
import InboxView from '../components/InboxView.vue';
|
||||||
import HistoryView from '../components/HistoryView.vue';
|
import HistoryView from '../components/HistoryView.vue';
|
||||||
import ImprintContainer from '../components/ImprintContainer.vue';
|
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||||
|
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const currentUserInfo = computed(() => {
|
const currentUserInfo = computed(() => {
|
||||||
if (!chatStore.currentConversation) return null;
|
if (!chatStore.currentConversation) return null;
|
||||||
return chatStore.users.find(u => u.userName === chatStore.currentConversation);
|
return chatStore.users.find(u => u.userName === chatStore.currentConversation);
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatGender(gender) {
|
|
||||||
const genderMap = {
|
|
||||||
'F': t('gender_female'),
|
|
||||||
'M': t('gender_male'),
|
|
||||||
'P': t('gender_pair'),
|
|
||||||
'TF': t('gender_trans_mf'),
|
|
||||||
'TM': t('gender_trans_fm')
|
|
||||||
};
|
|
||||||
return genderMap[gender] || gender;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Versuche Session wiederherzustellen
|
// Versuche Session wiederherzustellen
|
||||||
const sessionRestored = await chatStore.restoreSession();
|
const sessionRestored = await chatStore.restoreSession();
|
||||||
@@ -99,6 +125,24 @@ onMounted(async () => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: auto;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at left top, rgba(61, 134, 84, 0.2), transparent 24%),
|
||||||
|
radial-gradient(circle at right bottom, rgba(36, 92, 58, 0.12), transparent 26%),
|
||||||
|
linear-gradient(180deg, rgba(231, 241, 234, 0.95) 0%, rgba(237, 242, 238, 0.96) 48%, rgba(227, 236, 229, 0.98) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-box-login {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-content {
|
.chat-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -109,55 +153,120 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
.chat-header {
|
||||||
padding: 0.5em 1em;
|
padding: 0.7rem 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid #999;
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
background: linear-gradient(180deg, rgba(225, 239, 229, 0.92) 0%, rgba(247, 250, 248, 0.9) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-gender-M {
|
.chat-header-accent {
|
||||||
background-color: #0066CC;
|
width: 0.6rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-gender-F {
|
.chat-header-accent-M {
|
||||||
background-color: #FF4081;
|
background: linear-gradient(180deg, #5a94d2 0%, #467bb2 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-gender-P {
|
.chat-header-accent-F {
|
||||||
background-color: #FFC107;
|
background: linear-gradient(180deg, #ff7eaa 0%, #d85f8c 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-gender-TF {
|
.chat-header-accent-P {
|
||||||
background-color: #8E24AA;
|
background: linear-gradient(180deg, #e0ab46 0%, #c78a2c 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-gender-TM {
|
.chat-header-accent-TF {
|
||||||
background-color: #90caf9;
|
background: linear-gradient(180deg, #a37ac8 0%, #8b60af 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-accent-TM {
|
||||||
|
background: linear-gradient(180deg, #79b8d0 0%, #5fa2bf 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header-main {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header h2 {
|
.chat-header h2 {
|
||||||
margin: 0 0 0.3em 0;
|
margin: 0;
|
||||||
font-size: 1.5em;
|
font-size: 1rem;
|
||||||
color: #fff;
|
line-height: 1.2;
|
||||||
|
color: var(--color-text-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-info {
|
.chat-header-info {
|
||||||
font-size: 0.75em;
|
margin-top: 0.18rem;
|
||||||
color: #fff;
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 0.8em;
|
gap: 0.8rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
padding: 1em;
|
padding: 0.9rem 1rem;
|
||||||
background-color: #ffebee;
|
background-color: #fff1f1;
|
||||||
color: #c62828;
|
color: #a83f3f;
|
||||||
border: 1px solid #ef5350;
|
border: 1px solid #efc3c3;
|
||||||
margin: 1em;
|
margin: 0.9rem;
|
||||||
border-radius: 4px;
|
border-radius: 10px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
|
||||||
|
.command-table-container {
|
||||||
|
margin: 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-table-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-table-close {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-table-scroll {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-table th,
|
||||||
|
.command-table td {
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border-bottom: 1px solid #edf1ee;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-table th {
|
||||||
|
background: #f9fbfa;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
24
client/src/views/FeedbackView.vue
Normal file
24
client/src/views/FeedbackView.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-container">
|
||||||
|
<header class="header">
|
||||||
|
<div class="app-brand">
|
||||||
|
<span class="app-brand-mark">S</span>
|
||||||
|
<div class="app-brand-copy">
|
||||||
|
<span class="app-brand-eyebrow">SingleChat</span>
|
||||||
|
<h1>Feedback</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HeaderAdBanner />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<FeedbackPanel />
|
||||||
|
|
||||||
|
<ImprintContainer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FeedbackPanel from '../components/FeedbackPanel.vue';
|
||||||
|
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
||||||
|
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||||
|
</script>
|
||||||
992
client/src/views/MockupView.vue
Normal file
992
client/src/views/MockupView.vue
Normal file
@@ -0,0 +1,992 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mockup-page">
|
||||||
|
<header class="mockup-page-header">
|
||||||
|
<div>
|
||||||
|
<p class="mockup-page-eyebrow">SingleChat Redesign</p>
|
||||||
|
<h1>Mockup-Vergleich</h1>
|
||||||
|
</div>
|
||||||
|
<p class="mockup-page-copy">
|
||||||
|
Zwei jetzt klarer getrennte Richtungen: A bleibt kompakt und direkt, B arbeitet sichtbarer mit Farbflaechen und moderneren Layern. Beide zeigen staerkere Identifikationsfarben und eine schmalere Userliste.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mockup-compare">
|
||||||
|
<section class="mockup-column mockup-column-single">
|
||||||
|
<div class="mockup-column-header">
|
||||||
|
<div>
|
||||||
|
<p class="mockup-variant-label">Zielrichtung</p>
|
||||||
|
<h2>Polished Compact</h2>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Grundlage ist das modernere Design der zweiten Version, aber mit direkterer Sprache wie in Variante A, staerkerem Gruen im Header und einer einzeiligen, deutlich kompakteren Userliste.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mockup-shell mockup-shell-polished">
|
||||||
|
<header class="mockup-topbar">
|
||||||
|
<div class="mockup-brand">
|
||||||
|
<div class="mockup-brand-mark">S</div>
|
||||||
|
<div>
|
||||||
|
<p class="mockup-eyebrow">Design Preview</p>
|
||||||
|
<h3>SingleChat</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mockup-session">
|
||||||
|
<span class="mockup-chip">09:24 online</span>
|
||||||
|
<span class="mockup-chip mockup-chip-accent">Inbox 3</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="mockup-toolbar">
|
||||||
|
<button class="mockup-tool-button">Chat</button>
|
||||||
|
<button class="mockup-tool-button mockup-tool-button-active">Suche</button>
|
||||||
|
<button class="mockup-tool-button">Postfach</button>
|
||||||
|
<button class="mockup-tool-button">Verlauf</button>
|
||||||
|
<div class="mockup-toolbar-meta">
|
||||||
|
<span>Mara aktiv</span>
|
||||||
|
<span>04:18</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="mockup-layout">
|
||||||
|
<aside class="mockup-sidebar">
|
||||||
|
<div class="mockup-sidebar-header">
|
||||||
|
<h4>Online</h4>
|
||||||
|
<span>2.184</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mockup-user-list">
|
||||||
|
<button class="mockup-user mockup-user-active">
|
||||||
|
<span class="mockup-flag">DE</span>
|
||||||
|
<span class="mockup-user-copy">
|
||||||
|
<strong>Mara</strong>
|
||||||
|
<em>27 · F</em>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="mockup-user">
|
||||||
|
<span class="mockup-flag">NL</span>
|
||||||
|
<span class="mockup-user-copy">
|
||||||
|
<strong>AlexWave</strong>
|
||||||
|
<em>29 · TM</em>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="mockup-user">
|
||||||
|
<span class="mockup-flag">CH</span>
|
||||||
|
<span class="mockup-user-copy">
|
||||||
|
<strong>couple.sun</strong>
|
||||||
|
<em>31 · P</em>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="mockup-user">
|
||||||
|
<span class="mockup-flag">FR</span>
|
||||||
|
<span class="mockup-user-copy">
|
||||||
|
<strong>lina.n</strong>
|
||||||
|
<em>25 · TF</em>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="mockup-main">
|
||||||
|
<section class="mockup-chat-header">
|
||||||
|
<div class="mockup-chat-identity">
|
||||||
|
<span class="mockup-chat-accent mockup-chat-accent-f"></span>
|
||||||
|
<div>
|
||||||
|
<h4>Mara</h4>
|
||||||
|
<p>27 Jahre · Deutschland</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup-chat-meta">
|
||||||
|
<span class="mockup-badge">Online</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mockup-chat-window">
|
||||||
|
<article class="mockup-message mockup-message-other">
|
||||||
|
<p class="mockup-message-author">Mara</p>
|
||||||
|
<div class="mockup-bubble">
|
||||||
|
Hey, dein Profil ist mir gerade in der Liste aufgefallen.
|
||||||
|
</div>
|
||||||
|
<time>14:02</time>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="mockup-message mockup-message-self">
|
||||||
|
<p class="mockup-message-author">Du</p>
|
||||||
|
<div class="mockup-bubble">
|
||||||
|
Die Farben wirken ruhiger und die Flaechen deutlich geordneter.
|
||||||
|
</div>
|
||||||
|
<time>14:03</time>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="mockup-message mockup-message-other">
|
||||||
|
<p class="mockup-message-author">Mara</p>
|
||||||
|
<div class="mockup-bubble">
|
||||||
|
Ja, es bleibt vertraut, aber fuehlt sich praeziser an.
|
||||||
|
</div>
|
||||||
|
<time>14:04</time>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mockup-input-bar">
|
||||||
|
<button class="mockup-icon-button" aria-label="Smileys">:-)</button>
|
||||||
|
<input type="text" value="Nachricht senden oder /Befehl eingeben" readonly />
|
||||||
|
<button class="mockup-icon-button" aria-label="Bild">+</button>
|
||||||
|
<button class="mockup-send-button">Senden</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="mockup-footer">
|
||||||
|
<a href="#">Impressum</a>
|
||||||
|
<a href="#">Datenschutz</a>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mockup-mobile-device mockup-mobile-device-polished">
|
||||||
|
<div class="mockup-mobile-top">
|
||||||
|
<span>SingleChat</span>
|
||||||
|
<span class="mockup-mobile-pill">3</span>
|
||||||
|
</div>
|
||||||
|
<div class="mockup-mobile-chat-header">
|
||||||
|
<strong>Mara</strong>
|
||||||
|
<small>27 · DE</small>
|
||||||
|
</div>
|
||||||
|
<div class="mockup-mobile-messages">
|
||||||
|
<div class="mockup-mobile-bubble mockup-mobile-bubble-other">Kompakter, wirkt moderner.</div>
|
||||||
|
<div class="mockup-mobile-bubble mockup-mobile-bubble-self">Genau das ist hier die Richtung.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup-mobile-input">
|
||||||
|
<span>Nachricht...</span>
|
||||||
|
<button>Senden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mockup-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(61, 134, 84, 0.14), transparent 26%),
|
||||||
|
linear-gradient(180deg, #f6f8f6 0%, #edf1ee 100%);
|
||||||
|
color: #18201b;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-page-header {
|
||||||
|
max-width: 1360px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-page-eyebrow,
|
||||||
|
.mockup-variant-label,
|
||||||
|
.mockup-eyebrow {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6a766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-page-header h1,
|
||||||
|
.mockup-column-header h2,
|
||||||
|
.mockup-brand h3,
|
||||||
|
.mockup-sidebar-header h4,
|
||||||
|
.mockup-chat-identity h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-page-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-page-copy {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #5d695f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-compare {
|
||||||
|
max-width: 1360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-column {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-column-single {
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-column-header {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-column-header h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-column-header p:last-child {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #5d695f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 24px 60px rgba(31, 50, 39, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished {
|
||||||
|
border: 1px solid rgba(201, 213, 203, 0.9);
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(247, 250, 248, 0.94) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 70px rgba(31, 50, 39, 0.1),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-topbar {
|
||||||
|
height: 58px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid #dde5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-topbar {
|
||||||
|
background: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-topbar {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(208, 232, 216, 0.98) 0%, rgba(235, 245, 238, 0.94) 55%, rgba(247, 250, 248, 0.92) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-brand-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(180deg, #3d8654 0%, #245c3a 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-brand-mark {
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-brand h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-session {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #4e5a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-chip {
|
||||||
|
background: #eef2ef;
|
||||||
|
border: 1px solid #dde5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-chip-accent {
|
||||||
|
background: #e7f1ea;
|
||||||
|
color: #245c3a;
|
||||||
|
border-color: #c8dbc9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-chip {
|
||||||
|
background: rgba(241, 245, 242, 0.95);
|
||||||
|
border: 1px solid #d8e0da;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-chip-accent {
|
||||||
|
background: linear-gradient(180deg, #edf7f0 0%, #e1efe5 100%);
|
||||||
|
color: #245c3a;
|
||||||
|
border-color: #cadecf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-toolbar {
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid #dde5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-toolbar {
|
||||||
|
background: #f8faf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-toolbar {
|
||||||
|
background: rgba(247, 250, 248, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-tool-button {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #425047;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-tool-button-active {
|
||||||
|
background: #e7f1ea;
|
||||||
|
border-color: #c8dbc9;
|
||||||
|
color: #245c3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-tool-button-active {
|
||||||
|
background: linear-gradient(180deg, #dceee1 0%, #cfe6d6 100%);
|
||||||
|
border-color: #b8d4bf;
|
||||||
|
color: #1f4f32;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-toolbar-meta {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #627067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 188px minmax(0, 1fr);
|
||||||
|
min-height: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-sidebar {
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-right: 1px solid #dde5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-sidebar {
|
||||||
|
background: #f7f9f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-sidebar {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(247, 250, 247, 0.95) 0%, rgba(242, 246, 243, 0.92) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-sidebar-header h4 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-sidebar-header span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #68756d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-user {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-user-active {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #d9e2db;
|
||||||
|
box-shadow: 0 6px 14px rgba(35, 54, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-user {
|
||||||
|
border: 1px solid rgba(217, 225, 218, 0.8);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-user-active {
|
||||||
|
background: linear-gradient(180deg, rgba(236, 246, 239, 0.98) 0%, rgba(226, 239, 231, 0.96) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 18px rgba(35, 54, 42, 0.06),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-accent-f {
|
||||||
|
background: #d85f8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-accent-m {
|
||||||
|
background: #467bb2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-accent-p {
|
||||||
|
background: #c78a2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-accent-tf {
|
||||||
|
background: #8b60af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-accent-tm {
|
||||||
|
background: #5fa2bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-flag {
|
||||||
|
width: 28px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #506057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-flag {
|
||||||
|
background: #e9eeea;
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-flag {
|
||||||
|
background: linear-gradient(180deg, #f0f4f1 0%, #e7ede8 100%);
|
||||||
|
border: 1px solid #d5ded7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-copy {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-copy strong {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1c251f;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-user-copy em {
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #536159;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-main {
|
||||||
|
background: linear-gradient(180deg, #fbfcfb 0%, #f4f7f4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-main {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(61, 134, 84, 0.08), transparent 26%),
|
||||||
|
linear-gradient(180deg, #fbfdfb 0%, #f3f7f4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chat-header {
|
||||||
|
min-height: 68px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid #dde5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-chat-header {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-chat-header {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(235, 244, 237, 0.9) 0%, rgba(248, 251, 248, 0.8) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chat-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chat-accent {
|
||||||
|
width: 10px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chat-accent-f {
|
||||||
|
background: linear-gradient(180deg, #ff6f9f 0%, #d85f8c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chat-identity h4 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chat-identity p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #627067;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-badge {
|
||||||
|
background: #edf5ef;
|
||||||
|
border: 1px solid #d3e3d5;
|
||||||
|
color: #2f6f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-badge {
|
||||||
|
background: linear-gradient(180deg, #e4f2e8 0%, #d4e7da 100%);
|
||||||
|
border: 1px solid #c0d7c7;
|
||||||
|
color: #2a6440;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-chat-window {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 72%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-message-self {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-message-other {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-message-system {
|
||||||
|
align-self: center;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-message-author,
|
||||||
|
.mockup-message time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #748077;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-bubble {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-bubble {
|
||||||
|
border: 1px solid #dce3de;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-message-self .mockup-bubble {
|
||||||
|
background: #edf5ef;
|
||||||
|
border-color: #d4e3d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-bubble {
|
||||||
|
border: 1px solid rgba(217, 226, 219, 0.9);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 250, 247, 0.96) 100%);
|
||||||
|
box-shadow: 0 10px 18px rgba(35, 54, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-message-self .mockup-bubble {
|
||||||
|
background: linear-gradient(180deg, #dff0e4 0%, #d2e7d9 100%);
|
||||||
|
border-color: #c5dbcce8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-input-bar {
|
||||||
|
min-height: 68px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) 40px 96px;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid #dde5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-input-bar {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-input-bar {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(238, 245, 240, 0.92) 0%, rgba(247, 250, 248, 0.88) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-footer {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 18px;
|
||||||
|
border-top: 1px solid #dde5df;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-footer a {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #54635a;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-input-bar input {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #647068;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-input-bar input {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
background: #f9fbf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-input-bar input {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
background: linear-gradient(180deg, #fcfefc 0%, #f0f6f2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-icon-button,
|
||||||
|
.mockup-send-button {
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-icon-button {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
background: #f7faf7;
|
||||||
|
color: #3f4c44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-icon-button {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
background: linear-gradient(180deg, #fdfefd 0%, #edf4ef 100%);
|
||||||
|
color: #3f4c44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-send-button {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-send-button {
|
||||||
|
border: 1px solid #2d6944;
|
||||||
|
background: linear-gradient(180deg, #3d8654 0%, #2f6f46 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-send-button {
|
||||||
|
border: 1px solid #295f3d;
|
||||||
|
background: linear-gradient(180deg, #4a8d61 0%, #2c6240 100%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device {
|
||||||
|
width: 300px;
|
||||||
|
margin-top: 18px;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-calm {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
background: #fcfdfc;
|
||||||
|
box-shadow: 0 18px 40px rgba(31, 50, 39, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-polished {
|
||||||
|
border: 1px solid #d7dfd9;
|
||||||
|
background: linear-gradient(180deg, #fefefe 0%, #f5f8f6 100%);
|
||||||
|
box-shadow: 0 22px 48px rgba(31, 50, 39, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-pill {
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #e7f1ea;
|
||||||
|
color: #245c3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-user:nth-child(1) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(216, 95, 140, 0.16), transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-user:nth-child(2) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(70, 123, 178, 0.14), transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-user:nth-child(3) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(199, 138, 44, 0.16), transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-calm .mockup-user:nth-child(4) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(139, 96, 175, 0.14), transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-user:nth-child(1) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(216, 95, 140, 0.26), rgba(255, 255, 255, 0.68) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-user:nth-child(2) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(70, 123, 178, 0.22), rgba(255, 255, 255, 0.68) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-user:nth-child(3) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(199, 138, 44, 0.24), rgba(255, 255, 255, 0.68) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-shell-polished .mockup-user:nth-child(4) {
|
||||||
|
background-image: linear-gradient(90deg, rgba(139, 96, 175, 0.22), rgba(255, 255, 255, 0.68) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-chat-header,
|
||||||
|
.mockup-mobile-input {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-chat-header {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-calm .mockup-mobile-chat-header,
|
||||||
|
.mockup-mobile-device-calm .mockup-mobile-input {
|
||||||
|
background: #f2f6f3;
|
||||||
|
border: 1px solid #dbe3dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-polished .mockup-mobile-chat-header,
|
||||||
|
.mockup-mobile-device-polished .mockup-mobile-input {
|
||||||
|
background: linear-gradient(180deg, #f8fbf9 0%, #f0f5f2 100%);
|
||||||
|
border: 1px solid #dbe3dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-chat-header small {
|
||||||
|
color: #637068;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-bubble {
|
||||||
|
max-width: 82%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-calm .mockup-mobile-bubble-other {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dce3de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-calm .mockup-mobile-bubble-self {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: #edf5ef;
|
||||||
|
border: 1px solid #d4e3d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-polished .mockup-mobile-bubble-other {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fbf9 100%);
|
||||||
|
border: 1px solid #dce3de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-device-polished .mockup-mobile-bubble-self {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: linear-gradient(180deg, #eff7f1 0%, #e5f0e8 100%);
|
||||||
|
border: 1px solid #d4e3d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-input {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
color: #68756d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-mobile-input button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #2f6f46;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.mockup-page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-sidebar {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #dde5df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-toolbar-meta {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mockup-topbar {
|
||||||
|
height: auto;
|
||||||
|
padding: 14px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-session {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-input-bar {
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr) 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-input-bar .mockup-icon-button:last-of-type {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-message {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>SingleChat</h1>
|
<div class="app-brand">
|
||||||
|
<span class="app-brand-mark">S</span>
|
||||||
|
<div class="app-brand-copy">
|
||||||
|
<span class="app-brand-eyebrow">SingleChat</span>
|
||||||
|
<h1>Partner</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HeaderAdBanner />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MenuBar />
|
<MenuBar />
|
||||||
@@ -36,6 +43,7 @@ import axios from 'axios';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import MenuBar from '../components/MenuBar.vue';
|
import MenuBar from '../components/MenuBar.vue';
|
||||||
import UserList from '../components/UserList.vue';
|
import UserList from '../components/UserList.vue';
|
||||||
|
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
||||||
import ImprintContainer from '../components/ImprintContainer.vue';
|
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||||
import { useChatStore } from '../stores/chat';
|
import { useChatStore } from '../stores/chat';
|
||||||
|
|
||||||
@@ -52,4 +60,3 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -46,9 +46,17 @@ export default defineConfig({
|
|||||||
minify: 'terser',
|
minify: 'terser',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: {
|
manualChunks(id) {
|
||||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
if (id.includes('node_modules')) {
|
||||||
'socket-vendor': ['socket.io-client']
|
if (id.includes('vue') || id.includes('vue-router') || id.includes('pinia')) {
|
||||||
|
return 'vue-vendor';
|
||||||
|
}
|
||||||
|
if (id.includes('socket.io-client')) {
|
||||||
|
return 'socket-vendor';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Standard-Chunks Vite überlassen
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
logs/chat-users.example.json
Normal file
7
logs/chat-users.example.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"passwordHash": "sha256:REPLACE_WITH_REAL_HASH",
|
||||||
|
"rights": ["stat", "kick"]
|
||||||
|
}
|
||||||
|
]
|
||||||
11
logs/feedback.json
Normal file
11
logs/feedback.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "97f7163b-64ea-4e90-9644-4bd4b9adcf6b",
|
||||||
|
"createdAt": "2026-03-19T14:19:19.555Z",
|
||||||
|
"name": "comic",
|
||||||
|
"age": null,
|
||||||
|
"country": "",
|
||||||
|
"gender": "",
|
||||||
|
"comment": "Schöne Seite"
|
||||||
|
}
|
||||||
|
]
|
||||||
173
package-lock.json
generated
173
package-lock.json
generated
@@ -113,13 +113,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -133,29 +133,58 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.4",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||||
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
|
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"content-type": "~1.0.5",
|
"content-type": "~1.0.5",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "2.0.0",
|
"depd": "2.0.0",
|
||||||
"destroy": "1.2.0",
|
"destroy": "~1.2.0",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"qs": "6.13.0",
|
"qs": "~6.14.0",
|
||||||
"raw-body": "2.5.2",
|
"raw-body": "~2.5.3",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"unpipe": "1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8",
|
"node": ">= 0.8",
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser/node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/body-parser/node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -639,39 +668,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "1.20.3",
|
"body-parser": "~1.20.3",
|
||||||
"content-disposition": "0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "0.7.1",
|
"cookie": "~0.7.1",
|
||||||
"cookie-signature": "1.0.6",
|
"cookie-signature": "~1.0.6",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "2.0.0",
|
"depd": "2.0.0",
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "~2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "~1.0.3",
|
||||||
"etag": "~1.8.1",
|
"etag": "~1.8.1",
|
||||||
"finalhandler": "1.3.1",
|
"finalhandler": "~1.3.1",
|
||||||
"fresh": "0.5.2",
|
"fresh": "~0.5.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.0",
|
||||||
"merge-descriptors": "1.0.3",
|
"merge-descriptors": "1.0.3",
|
||||||
"methods": "~1.1.2",
|
"methods": "~1.1.2",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "6.13.0",
|
"qs": "~6.14.0",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "0.19.0",
|
"send": "~0.19.0",
|
||||||
"serve-static": "1.16.2",
|
"serve-static": "~1.16.2",
|
||||||
"setprototypeof": "1.2.0",
|
"setprototypeof": "1.2.0",
|
||||||
"statuses": "2.0.1",
|
"statuses": "~2.0.1",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"utils-merge": "1.0.1",
|
"utils-merge": "1.0.1",
|
||||||
"vary": "~1.1.2"
|
"vary": "~1.1.2"
|
||||||
@@ -709,15 +738,6 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/express/node_modules/cookie": {
|
|
||||||
"version": "0.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
|
||||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
|
||||||
@@ -967,9 +987,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1180,12 +1200,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.0.6"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -1213,20 +1233,49 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/raw-body": {
|
"node_modules/raw-body": {
|
||||||
"version": "2.5.2",
|
"version": "2.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||||
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
|
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "3.1.2",
|
"bytes": "~3.1.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"unpipe": "1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/raw-body/node_modules/http-errors": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"depd": "~2.0.0",
|
||||||
|
"inherits": "~2.0.4",
|
||||||
|
"setprototypeof": "~1.2.0",
|
||||||
|
"statuses": "~2.0.2",
|
||||||
|
"toidentifier": "~1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/raw-body/node_modules/statuses": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
@@ -1512,22 +1561,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser": {
|
"node_modules/socket.io-parser": {
|
||||||
"version": "4.2.4",
|
"version": "4.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@socket.io/component-emitter": "~3.1.0",
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
"debug": "~4.3.1"
|
"debug": "~4.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socket.io-parser/node_modules/debug": {
|
"node_modules/socket.io-parser/node_modules/debug": {
|
||||||
"version": "4.3.7",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
|
|||||||
@@ -83,15 +83,12 @@ function ensureChatUsersFile(__dirname) {
|
|||||||
if (existsSync(usersPath)) {
|
if (existsSync(usersPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Security: never create predictable default credentials.
|
||||||
const defaultUsers = [
|
// Admin users must be configured explicitly in logs/chat-users.json.
|
||||||
{
|
writeFileSync(usersPath, '[]\n', 'utf-8');
|
||||||
username: 'admin',
|
console.warn(
|
||||||
passwordHash: `sha256:${sha256('changeme123')}`,
|
`[Auth] ${CHAT_USERS_FILE_NAME} wurde neu erstellt. Bitte mindestens einen Admin-User mit Passwort-Hash konfigurieren.`
|
||||||
rights: [CHAT_RIGHTS.STAT, CHAT_RIGHTS.KICK]
|
);
|
||||||
}
|
|
||||||
];
|
|
||||||
writeFileSync(usersPath, JSON.stringify(defaultUsers, null, 2), 'utf-8');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadChatUsers(__dirname) {
|
function loadChatUsers(__dirname) {
|
||||||
@@ -323,9 +320,17 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
downloadCountries();
|
downloadCountries();
|
||||||
setInterval(downloadCountries, 24 * 60 * 60 * 1000); // Täglich aktualisieren
|
setInterval(downloadCountries, 24 * 60 * 60 * 1000); // Täglich aktualisieren
|
||||||
|
|
||||||
function sendCommandResult(socket, lines) {
|
function sendCommandResult(socket, lines, kind = 'info') {
|
||||||
const payload = Array.isArray(lines) ? lines : [String(lines)];
|
const payload = Array.isArray(lines) ? lines : [String(lines)];
|
||||||
socket.emit('commandResult', { lines: payload });
|
socket.emit('commandResult', { lines: payload, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCommandTable(socket, title, columns, rows) {
|
||||||
|
socket.emit('commandTable', {
|
||||||
|
title: String(title || 'Ausgabe'),
|
||||||
|
columns: Array.isArray(columns) ? columns.map((c) => String(c)) : [],
|
||||||
|
rows: Array.isArray(rows) ? rows : []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasRight(client, right) {
|
function hasRight(client, right) {
|
||||||
@@ -399,15 +404,14 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
const sub = (parts[1] || '').toLowerCase();
|
const sub = (parts[1] || '').toLowerCase();
|
||||||
|
|
||||||
if (!sub || sub === 'help') {
|
if (!sub || sub === 'help') {
|
||||||
sendCommandResult(socket, [
|
sendCommandTable(socket, 'Hilfe: Statistik-Befehle', ['Befehl', 'Beschreibung'], [
|
||||||
'Stat-Befehle:',
|
['/stat today', 'Logins des heutigen Tages'],
|
||||||
'/stat today',
|
['/stat date YYYY-MM-DD', 'Logins an einem bestimmten Datum'],
|
||||||
'/stat date YYYY-MM-DD',
|
['/stat range YYYY-MM-DD YYYY-MM-DD', 'Logins pro Tag im Zeitraum'],
|
||||||
'/stat range YYYY-MM-DD YYYY-MM-DD',
|
['/stat ages', 'Jüngster und ältester Nutzer'],
|
||||||
'/stat ages',
|
['/stat names', 'Häufigkeit der verwendeten Namen'],
|
||||||
'/stat names',
|
['/stat countries', 'Häufigkeit der Länder'],
|
||||||
'/stat countries',
|
['/all-stats', 'Zusammenfassung wichtiger Kennzahlen']
|
||||||
'/all-stats'
|
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -415,7 +419,13 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
if (sub === 'today') {
|
if (sub === 'today') {
|
||||||
const day = new Date().toISOString().slice(0, 10);
|
const day = new Date().toISOString().slice(0, 10);
|
||||||
const dayRecords = records.filter((r) => r.day === day);
|
const dayRecords = records.filter((r) => r.day === day);
|
||||||
sendCommandResult(socket, `Logins heute (${day}): ${dayRecords.length}`);
|
const uniqueUsersToday = Array.from(new Set(dayRecords.map((r) => r.userName)))
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'de'));
|
||||||
|
sendCommandTable(socket, 'Statistik: Heute', ['Metrik', 'Wert'], [
|
||||||
|
['Tag', day],
|
||||||
|
['Logins', dayRecords.length],
|
||||||
|
['Heute eingeloggt', uniqueUsersToday.length > 0 ? uniqueUsersToday.join(', ') : '-']
|
||||||
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +436,7 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dayRecords = records.filter((r) => r.day === day);
|
const dayRecords = records.filter((r) => r.day === day);
|
||||||
sendCommandResult(socket, `Logins am ${day}: ${dayRecords.length}`);
|
sendCommandTable(socket, 'Statistik: Datum', ['Tag', 'Logins'], [[day, dayRecords.length]]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,8 +450,8 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
const filtered = records.filter((r) => r.day >= from && r.day <= to);
|
const filtered = records.filter((r) => r.day >= from && r.day <= to);
|
||||||
const perDay = aggregateTop(filtered, (r) => r.day, 1000)
|
const perDay = aggregateTop(filtered, (r) => r.day, 1000)
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.map(([day, count]) => `${day}: ${count}`);
|
.map(([day, count]) => [day, count]);
|
||||||
sendCommandResult(socket, [`Logins ${from} bis ${to}: ${filtered.length}`, ...perDay]);
|
sendCommandTable(socket, `Statistik: Zeitraum ${from} bis ${to}`, ['Tag', 'Logins'], perDay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,25 +461,37 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
const maxAge = Math.max(...ages);
|
const maxAge = Math.max(...ages);
|
||||||
const youngest = records.find((r) => r.age === minAge);
|
const youngest = records.find((r) => r.age === minAge);
|
||||||
const oldest = records.find((r) => r.age === maxAge);
|
const oldest = records.find((r) => r.age === maxAge);
|
||||||
sendCommandResult(socket, [
|
sendCommandTable(socket, 'Statistik: Alter', ['Kategorie', 'Name', 'Alter'], [
|
||||||
`Jüngster Nutzer: ${youngest.userName} (${youngest.age})`,
|
['Jüngster Nutzer', youngest.userName, youngest.age],
|
||||||
`Ältester Nutzer: ${oldest.userName} (${oldest.age})`
|
['Ältester Nutzer', oldest.userName, oldest.age]
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'names') {
|
if (sub === 'names') {
|
||||||
const topNames = aggregateTop(records, (r) => r.userName, 20);
|
const topNames = aggregateTop(records, (r) => r.userName, 20);
|
||||||
sendCommandResult(socket, [
|
sendCommandTable(
|
||||||
`Namen gesamt (verschieden): ${new Set(records.map((r) => r.userName)).size}`,
|
socket,
|
||||||
...topNames.map(([name, count]) => `${name}: ${count}`)
|
`Statistik: Namen (gesamt verschieden: ${new Set(records.map((r) => r.userName)).size})`,
|
||||||
]);
|
['Name', 'Anzahl', 'Letzter Login'],
|
||||||
|
topNames.map(([name, count]) => {
|
||||||
|
const latestRecord = records
|
||||||
|
.filter((r) => r.userName === name)
|
||||||
|
.sort((a, b) => b.date - a.date)[0];
|
||||||
|
return [name, count, latestRecord ? latestRecord.timestamp : '-'];
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub === 'countries') {
|
if (sub === 'countries') {
|
||||||
const topCountries = aggregateTop(records, (r) => r.country, 20);
|
const topCountries = aggregateTop(records, (r) => r.country, 20);
|
||||||
sendCommandResult(socket, topCountries.map(([country, count]) => `${country}: ${count}`));
|
sendCommandTable(
|
||||||
|
socket,
|
||||||
|
'Statistik: Länder',
|
||||||
|
['Land', 'Anzahl'],
|
||||||
|
topCountries.map(([country, count]) => [country, count])
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +503,15 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
sendCommandResult(socket, 'Keine Berechtigung: Recht "stat" fehlt.');
|
sendCommandResult(socket, 'Keine Berechtigung: Recht "stat" fehlt.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendCommandResult(socket, buildAllStats(readLoginRecords()));
|
const lines = buildAllStats(readLoginRecords());
|
||||||
|
const rows = lines.map((line) => {
|
||||||
|
const separatorIndex = line.indexOf(':');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return [line, ''];
|
||||||
|
}
|
||||||
|
return [line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()];
|
||||||
|
});
|
||||||
|
sendCommandTable(socket, 'Statistik: Übersicht', ['Metrik', 'Wert'], rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeKickCommand(socket, client, parts) {
|
function executeKickCommand(socket, client, parts) {
|
||||||
@@ -539,15 +569,16 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
function executeCommand(socket, client, rawInput) {
|
function executeCommand(socket, client, rawInput) {
|
||||||
const input = rawInput.trim();
|
const input = rawInput.trim();
|
||||||
|
|
||||||
if (client.pendingChatLogin) {
|
// Laufender Login-Dialog: Nur Eingaben ohne Slash als Username/Passwort behandeln.
|
||||||
|
if (client.pendingChatLogin && !input.startsWith('/')) {
|
||||||
if (client.pendingChatLogin.step === 'username') {
|
if (client.pendingChatLogin.step === 'username') {
|
||||||
const enteredUser = input;
|
const enteredUser = input;
|
||||||
if (!enteredUser) {
|
if (!enteredUser) {
|
||||||
sendCommandResult(socket, 'Username darf nicht leer sein. Bitte Username eingeben:');
|
sendCommandResult(socket, 'Username darf nicht leer sein. Bitte Username eingeben:', 'loginPromptUsername');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
client.pendingChatLogin = { step: 'password', username: enteredUser };
|
client.pendingChatLogin = { step: 'password', username: enteredUser };
|
||||||
sendCommandResult(socket, 'Passwort eingeben:');
|
sendCommandResult(socket, 'Passwort eingeben:', 'loginPromptPassword');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,17 +587,23 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
const auth = verifyChatUser(username, input);
|
const auth = verifyChatUser(username, input);
|
||||||
client.pendingChatLogin = null;
|
client.pendingChatLogin = null;
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
sendCommandResult(socket, 'Login fehlgeschlagen. Benutzername oder Passwort falsch.');
|
sendCommandResult(socket, 'Login fehlgeschlagen. Benutzername oder Passwort falsch.', 'loginError');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
client.chatAuth = auth;
|
client.chatAuth = auth;
|
||||||
sendCommandResult(
|
sendCommandResult(
|
||||||
socket,
|
socket,
|
||||||
`Login erfolgreich als ${auth.username}. Rechte: ${Array.from(auth.rights).join(', ') || 'keine'}`
|
`Login erfolgreich als ${auth.username}. Rechte: ${Array.from(auth.rights).join(', ') || 'keine'}`,
|
||||||
|
'loginSuccess'
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
} else if (client.pendingChatLogin && input.startsWith('/')) {
|
||||||
|
// Ein neuer /Befehl bricht den Login-Vorgang ab.
|
||||||
|
client.pendingChatLogin = null;
|
||||||
|
sendCommandResult(socket, 'Login-Vorgang abgebrochen.', 'loginAbort');
|
||||||
|
// und läuft unten als normaler Befehl weiter
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!input.startsWith('/')) return false;
|
if (!input.startsWith('/')) return false;
|
||||||
@@ -578,10 +615,10 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
const username = (parts[1] || '').trim();
|
const username = (parts[1] || '').trim();
|
||||||
if (username) {
|
if (username) {
|
||||||
client.pendingChatLogin = { step: 'password', username };
|
client.pendingChatLogin = { step: 'password', username };
|
||||||
sendCommandResult(socket, 'Passwort eingeben:');
|
sendCommandResult(socket, 'Passwort eingeben:', 'loginPromptPassword');
|
||||||
} else {
|
} else {
|
||||||
client.pendingChatLogin = { step: 'username', username: '' };
|
client.pendingChatLogin = { step: 'username', username: '' };
|
||||||
sendCommandResult(socket, 'Username eingeben:');
|
sendCommandResult(socket, 'Username eingeben:', 'loginPromptUsername');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -594,19 +631,33 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
socket,
|
socket,
|
||||||
wasLoggedIn
|
wasLoggedIn
|
||||||
? 'Admin/Command-Login wurde abgemeldet.'
|
? 'Admin/Command-Login wurde abgemeldet.'
|
||||||
: 'Es war kein Admin/Command-Login aktiv.'
|
: 'Es war kein Admin/Command-Login aktiv.',
|
||||||
|
'loginLogout'
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command === '/whoami-rights') {
|
if (command === '/whoami-rights') {
|
||||||
if (!client.chatAuth) {
|
if (!client.chatAuth) {
|
||||||
sendCommandResult(socket, 'Nicht per Command-Login angemeldet.');
|
sendCommandResult(socket, 'Nicht per Command-Login angemeldet.', 'whoami');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
sendCommandResult(socket, [
|
sendCommandResult(socket, [
|
||||||
`Angemeldet als: ${client.chatAuth.username}`,
|
`Angemeldet als: ${client.chatAuth.username}`,
|
||||||
`Rechte: ${Array.from(client.chatAuth.rights).join(', ') || 'keine'}`
|
`Rechte: ${Array.from(client.chatAuth.rights).join(', ') || 'keine'}`
|
||||||
|
], 'whoami');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === '/help' || command === '/?') {
|
||||||
|
sendCommandTable(socket, 'Hilfe: Verfügbare Befehle', ['Befehl', 'Beschreibung'], [
|
||||||
|
['/login [username]', 'Admin-/Command-Login starten'],
|
||||||
|
['/logout-admin', 'Admin-/Command-Login beenden'],
|
||||||
|
['/whoami-rights', 'Aktuelle Admin-Rechte anzeigen'],
|
||||||
|
['/stat help', 'Hilfe zu Statistikbefehlen anzeigen'],
|
||||||
|
['/all-stats', 'Zusammenfassung wichtiger Statistiken'],
|
||||||
|
['/kick <username>', 'Benutzer aus dem Chat werfen'],
|
||||||
|
['/help oder /?', 'Diese Hilfe anzeigen']
|
||||||
]);
|
]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -626,7 +677,7 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendCommandResult(socket, `Unbekannter Befehl: ${command}`);
|
sendCommandResult(socket, `Unbekannter Befehl: ${command}`, 'unknown');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,4 +1387,3 @@ export function setupBroadcast(io, __dirname) {
|
|||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
server/chat-auth.js
Normal file
73
server/chat-auth.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const CHAT_USERS_FILE_NAME = 'chat-users.json';
|
||||||
|
|
||||||
|
function ensureLogsDir(baseDir) {
|
||||||
|
const logsDir = join(baseDir, '../logs');
|
||||||
|
if (!existsSync(logsDir)) {
|
||||||
|
mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return logsDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChatUsersPath(baseDir) {
|
||||||
|
return join(ensureLogsDir(baseDir), CHAT_USERS_FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return crypto.createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureChatUsersFile(baseDir) {
|
||||||
|
const usersPath = getChatUsersPath(baseDir);
|
||||||
|
if (existsSync(usersPath)) return;
|
||||||
|
writeFileSync(usersPath, '[]\n', 'utf-8');
|
||||||
|
console.warn(
|
||||||
|
`[Auth] ${CHAT_USERS_FILE_NAME} wurde neu erstellt. Bitte mindestens einen Admin-User mit Passwort-Hash konfigurieren.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadChatUsers(baseDir) {
|
||||||
|
ensureChatUsersFile(baseDir);
|
||||||
|
const usersPath = getChatUsersPath(baseDir);
|
||||||
|
const raw = readFileSync(usersPath, 'utf-8').trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
let users = [];
|
||||||
|
try {
|
||||||
|
users = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Ungültige ${CHAT_USERS_FILE_NAME}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(users)) {
|
||||||
|
throw new Error(`${CHAT_USERS_FILE_NAME} muss ein Array sein`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users
|
||||||
|
.filter((entry) => entry && typeof entry.username === 'string')
|
||||||
|
.map((entry) => ({
|
||||||
|
username: entry.username.trim(),
|
||||||
|
passwordHash: typeof entry.passwordHash === 'string' ? entry.passwordHash.trim() : '',
|
||||||
|
rights: Array.isArray(entry.rights) ? entry.rights.map((r) => String(r).toLowerCase()) : []
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.username && entry.passwordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyChatUser(baseDir, username, password) {
|
||||||
|
if (!username || !password) return null;
|
||||||
|
const normalizedUser = String(username).trim();
|
||||||
|
const passwordHash = sha256(password);
|
||||||
|
const user = loadChatUsers(baseDir).find(
|
||||||
|
(entry) => entry.username.toLowerCase() === normalizedUser.toLowerCase() && entry.passwordHash === passwordHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: user.username,
|
||||||
|
rights: new Set(user.rights)
|
||||||
|
};
|
||||||
|
}
|
||||||
55
server/feedback-store.js
Normal file
55
server/feedback-store.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const FEEDBACK_FILE_NAME = 'feedback.json';
|
||||||
|
|
||||||
|
function ensureLogsDir(baseDir) {
|
||||||
|
const logsDir = join(baseDir, '../logs');
|
||||||
|
if (!existsSync(logsDir)) {
|
||||||
|
mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
return logsDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFeedbackPath(baseDir) {
|
||||||
|
return join(ensureLogsDir(baseDir), FEEDBACK_FILE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureFeedbackFile(baseDir) {
|
||||||
|
const feedbackPath = getFeedbackPath(baseDir);
|
||||||
|
if (!existsSync(feedbackPath)) {
|
||||||
|
writeFileSync(feedbackPath, '[]\n', 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFeedback(baseDir) {
|
||||||
|
ensureFeedbackFile(baseDir);
|
||||||
|
const feedbackPath = getFeedbackPath(baseDir);
|
||||||
|
const raw = readFileSync(feedbackPath, 'utf-8').trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Ungültige ${FEEDBACK_FILE_NAME}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFeedback(baseDir, items) {
|
||||||
|
const feedbackPath = getFeedbackPath(baseDir);
|
||||||
|
writeFileSync(feedbackPath, `${JSON.stringify(items, null, 2)}\n`, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFeedbackEntry(input) {
|
||||||
|
return {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
name: typeof input.name === 'string' ? input.name.trim() : '',
|
||||||
|
age: Number.isFinite(input.age) ? input.age : null,
|
||||||
|
country: typeof input.country === 'string' ? input.country.trim() : '',
|
||||||
|
gender: typeof input.gender === 'string' ? input.gender.trim() : '',
|
||||||
|
comment: typeof input.comment === 'string' ? input.comment.trim() : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { readFileSync, existsSync } from 'fs';
|
import { readFileSync, existsSync } from 'fs';
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
|
import { loadFeedback } from './feedback-store.js';
|
||||||
|
|
||||||
|
const SITE_URL = 'https://ypchat.net';
|
||||||
|
const DEFAULT_IMAGE = `${SITE_URL}/static/favicon.png`;
|
||||||
|
|
||||||
// SEO-Meta-Daten für verschiedene Routen
|
|
||||||
const seoData = {
|
const seoData = {
|
||||||
'/': {
|
'/': {
|
||||||
title: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
title: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||||
@@ -10,8 +13,17 @@ const seoData = {
|
|||||||
ogTitle: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
ogTitle: 'SingleChat - Chat, Single-Chat und Bildaustausch',
|
||||||
ogDescription: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
ogDescription: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
ogUrl: 'https://ypchat.net/',
|
ogUrl: `${SITE_URL}/`,
|
||||||
ogImage: 'https://ypchat.net/static/favicon.png'
|
ogImage: DEFAULT_IMAGE,
|
||||||
|
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||||
|
schema: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'SingleChat',
|
||||||
|
url: `${SITE_URL}/`,
|
||||||
|
description: 'Willkommen auf SingleChat - deine erste Adresse für Chat, Single-Chat und Bildaustausch.',
|
||||||
|
inLanguage: 'de-DE'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
'/partners': {
|
'/partners': {
|
||||||
title: 'Partner - SingleChat',
|
title: 'Partner - SingleChat',
|
||||||
@@ -20,162 +32,214 @@ const seoData = {
|
|||||||
ogTitle: 'Partner - SingleChat',
|
ogTitle: 'Partner - SingleChat',
|
||||||
ogDescription: 'Unsere Partner und befreundete Seiten.',
|
ogDescription: 'Unsere Partner und befreundete Seiten.',
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
ogUrl: 'https://ypchat.net/partners',
|
ogUrl: `${SITE_URL}/partners`,
|
||||||
ogImage: 'https://ypchat.net/static/favicon.png'
|
ogImage: DEFAULT_IMAGE,
|
||||||
|
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||||
|
schema: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: 'Partner - SingleChat',
|
||||||
|
url: `${SITE_URL}/partners`,
|
||||||
|
description: 'Unsere Partner und befreundete Seiten. Entdecke weitere interessante Angebote und Communities.',
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'SingleChat',
|
||||||
|
url: `${SITE_URL}/`
|
||||||
|
},
|
||||||
|
inLanguage: 'de-DE'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'/feedback': {
|
||||||
|
title: 'Feedback - SingleChat',
|
||||||
|
description: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||||
|
keywords: 'SingleChat Feedback, Kommentare, Rueckmeldungen, Verbesserungsvorschlaege',
|
||||||
|
ogTitle: 'Feedback - SingleChat',
|
||||||
|
ogDescription: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||||
|
ogType: 'website',
|
||||||
|
ogUrl: `${SITE_URL}/feedback`,
|
||||||
|
ogImage: DEFAULT_IMAGE,
|
||||||
|
robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
|
||||||
|
schema: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'CollectionPage',
|
||||||
|
name: 'Feedback - SingleChat',
|
||||||
|
url: `${SITE_URL}/feedback`,
|
||||||
|
description: 'Oeffentliche Rueckmeldungen, Meinungen und Verbesserungsvorschlaege zu SingleChat.',
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: 'SingleChat',
|
||||||
|
url: `${SITE_URL}/`
|
||||||
|
},
|
||||||
|
inLanguage: 'de-DE'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// HTML-Template für Pre-Rendering
|
function escapeHtml(value = '') {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertMetaTag(html, name, content, attribute = 'name') {
|
||||||
|
const escapedContent = escapeHtml(content);
|
||||||
|
const regex = new RegExp(`<meta\\s+${attribute}="${name}"[^>]*>`, 'g');
|
||||||
|
const tag = `<meta ${attribute}="${name}" content="${escapedContent}">`;
|
||||||
|
|
||||||
|
if (regex.test(html)) {
|
||||||
|
return html.replace(regex, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace('</head>', ` ${tag}\n</head>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertLinkTag(html, rel, href) {
|
||||||
|
const escapedHref = escapeHtml(href);
|
||||||
|
const regex = new RegExp(`<link\\s+rel="${rel}"[^>]*>`, 'g');
|
||||||
|
const tag = `<link rel="${rel}" href="${escapedHref}">`;
|
||||||
|
|
||||||
|
if (regex.test(html)) {
|
||||||
|
return html.replace(regex, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace('</head>', ` ${tag}\n</head>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertJsonLd(html, schema) {
|
||||||
|
const tag = schema
|
||||||
|
? `<script type="application/ld+json" id="seo-json-ld">${JSON.stringify(schema)}</script>`
|
||||||
|
: '<script type="application/ld+json" id="seo-json-ld"></script>';
|
||||||
|
|
||||||
|
if (html.includes('id="seo-json-ld"')) {
|
||||||
|
return html.replace(/<script type="application\/ld\+json" id="seo-json-ld">.*?<\/script>/s, tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return html.replace('</head>', ` ${tag}\n</head>`);
|
||||||
|
}
|
||||||
|
|
||||||
function generateHTML(route, meta, __dirname) {
|
function generateHTML(route, meta, __dirname) {
|
||||||
// Versuche, die gebaute index.html zu lesen
|
|
||||||
const distIndexPath = join(__dirname, '../docroot/dist/index.html');
|
const distIndexPath = join(__dirname, '../docroot/dist/index.html');
|
||||||
|
|
||||||
console.log('[SEO] Prüfe gebaute index.html:', distIndexPath);
|
|
||||||
console.log('[SEO] Datei existiert:', existsSync(distIndexPath));
|
|
||||||
|
|
||||||
if (!existsSync(distIndexPath)) {
|
if (!existsSync(distIndexPath)) {
|
||||||
// Fallback: Gebaute index.html nicht gefunden
|
|
||||||
console.error('WARNUNG: Gebaute index.html nicht gefunden:', distIndexPath);
|
console.error('WARNUNG: Gebaute index.html nicht gefunden:', distIndexPath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verwende die gebaute index.html (mit korrekten Asset-Pfaden von Vite)
|
let html = readFileSync(distIndexPath, 'utf-8');
|
||||||
let baseHTML = readFileSync(distIndexPath, 'utf-8');
|
|
||||||
console.log('[SEO] Gebaute HTML geladen, Länge:', baseHTML.length);
|
|
||||||
console.log('[SEO] Enthält Script-Tags:', baseHTML.includes('<script'));
|
|
||||||
|
|
||||||
// Ersetze Meta-Tags in der gebauten HTML
|
html = html.replace(/<title>.*?<\/title>/, `<title>${escapeHtml(meta.title)}</title>`);
|
||||||
baseHTML = baseHTML.replace(/<title>.*?<\/title>/, `<title>${meta.title}</title>`);
|
html = upsertMetaTag(html, 'description', meta.description);
|
||||||
|
html = upsertMetaTag(html, 'keywords', meta.keywords);
|
||||||
|
html = upsertMetaTag(html, 'robots', meta.robots);
|
||||||
|
html = upsertMetaTag(html, 'theme-color', '#2f6f46');
|
||||||
|
|
||||||
// Ersetze oder füge description hinzu
|
html = upsertMetaTag(html, 'og:title', meta.ogTitle, 'property');
|
||||||
if (baseHTML.includes('<meta name="description"')) {
|
html = upsertMetaTag(html, 'og:description', meta.ogDescription, 'property');
|
||||||
baseHTML = baseHTML.replace(/<meta name="description"[^>]*>/g, `<meta name="description" content="${meta.description}">`);
|
html = upsertMetaTag(html, 'og:type', meta.ogType, 'property');
|
||||||
} else {
|
html = upsertMetaTag(html, 'og:url', meta.ogUrl, 'property');
|
||||||
baseHTML = baseHTML.replace('</head>', ` <meta name="description" content="${meta.description}">\n</head>`);
|
html = upsertMetaTag(html, 'og:image', meta.ogImage, 'property');
|
||||||
|
html = upsertMetaTag(html, 'og:site_name', 'SingleChat', 'property');
|
||||||
|
html = upsertMetaTag(html, 'og:locale', 'de_DE', 'property');
|
||||||
|
|
||||||
|
html = upsertMetaTag(html, 'twitter:card', 'summary_large_image');
|
||||||
|
html = upsertMetaTag(html, 'twitter:title', meta.ogTitle);
|
||||||
|
html = upsertMetaTag(html, 'twitter:description', meta.ogDescription);
|
||||||
|
html = upsertMetaTag(html, 'twitter:image', meta.ogImage);
|
||||||
|
|
||||||
|
html = upsertLinkTag(html, 'canonical', meta.ogUrl);
|
||||||
|
html = upsertJsonLd(html, meta.schema);
|
||||||
|
|
||||||
|
if (route === '/feedback') {
|
||||||
|
const feedbackItems = loadFeedback(__dirname)
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
const feedbackMarkup = feedbackItems.length > 0
|
||||||
|
? feedbackItems.map((item) => {
|
||||||
|
const metaLine = [item.country, item.age, item.gender].filter(Boolean).join(' · ');
|
||||||
|
return `<article style="border:1px solid #d7dfd9;border-radius:12px;padding:14px 16px;margin-bottom:12px;background:#fff;">
|
||||||
|
<strong style="display:block;color:#18201b;">${escapeHtml(item.name || 'Anonym')}</strong>
|
||||||
|
${metaLine ? `<div style="font-size:12px;color:#637067;margin-top:4px;">${escapeHtml(metaLine)}</div>` : ''}
|
||||||
|
<div style="font-size:12px;color:#637067;margin-top:4px;">${escapeHtml(new Date(item.createdAt).toLocaleString('de-DE'))}</div>
|
||||||
|
<p style="margin-top:10px;color:#2c362f;white-space:pre-wrap;">${escapeHtml(item.comment)}</p>
|
||||||
|
</article>`;
|
||||||
|
}).join('\n')
|
||||||
|
: '<p>Noch kein Feedback vorhanden.</p>';
|
||||||
|
|
||||||
|
const preview = `<section style="max-width:960px;margin:24px auto;padding:0 16px;">
|
||||||
|
<h2 style="font:600 28px/1.15 sans-serif;color:#18201b;margin:0 0 10px;">Feedback zu SingleChat</h2>
|
||||||
|
<p style="font:400 15px/1.5 sans-serif;color:#4f5d54;margin:0 0 18px;">Oeffentliche Rueckmeldungen und Verbesserungsvorschlaege.</p>
|
||||||
|
${feedbackMarkup}
|
||||||
|
</section>`;
|
||||||
|
|
||||||
|
html = html.replace('<div id="app"></div>', `<div id="app">${preview}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ersetze oder füge keywords hinzu
|
return html;
|
||||||
if (baseHTML.includes('<meta name="keywords"')) {
|
|
||||||
baseHTML = baseHTML.replace(/<meta name="keywords"[^>]*>/g, `<meta name="keywords" content="${meta.keywords}">`);
|
|
||||||
} else {
|
|
||||||
baseHTML = baseHTML.replace('</head>', ` <meta name="keywords" content="${meta.keywords}">\n</head>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ersetze oder füge Open Graph Tags hinzu
|
|
||||||
const ogTags = `
|
|
||||||
<meta property="og:title" content="${meta.ogTitle}">
|
|
||||||
<meta property="og:description" content="${meta.ogDescription}">
|
|
||||||
<meta property="og:type" content="${meta.ogType}">
|
|
||||||
<meta property="og:url" content="${meta.ogUrl}">
|
|
||||||
<meta property="og:image" content="${meta.ogImage}">
|
|
||||||
<meta name="twitter:card" content="summary">
|
|
||||||
<meta name="twitter:title" content="${meta.ogTitle}">
|
|
||||||
<meta name="twitter:description" content="${meta.ogDescription}">
|
|
||||||
<meta name="twitter:image" content="${meta.ogImage}">
|
|
||||||
<link rel="canonical" href="${meta.ogUrl}">`;
|
|
||||||
|
|
||||||
// Entferne alte OG/Twitter/Canonical Tags falls vorhanden (nur Meta-Tags, keine Script-Tags!)
|
|
||||||
baseHTML = baseHTML.replace(/<meta property="og:[^>]*>/g, '');
|
|
||||||
baseHTML = baseHTML.replace(/<meta name="twitter:[^>]*>/g, '');
|
|
||||||
baseHTML = baseHTML.replace(/<link rel="canonical"[^>]*>/g, '');
|
|
||||||
|
|
||||||
// Füge neue Tags vor </head> ein (aber NACH den Script-Tags!)
|
|
||||||
// Finde die Position von </head> und füge die Tags davor ein
|
|
||||||
const headEndIndex = baseHTML.indexOf('</head>');
|
|
||||||
if (headEndIndex !== -1) {
|
|
||||||
baseHTML = baseHTML.substring(0, headEndIndex) + ogTags + '\n' + baseHTML.substring(headEndIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Füge robots meta hinzu falls nicht vorhanden
|
|
||||||
if (!baseHTML.includes('<meta name="robots"')) {
|
|
||||||
const headEndIndex2 = baseHTML.indexOf('</head>');
|
|
||||||
if (headEndIndex2 !== -1) {
|
|
||||||
baseHTML = baseHTML.substring(0, headEndIndex2) + ` <meta name="robots" content="index, follow">\n` + baseHTML.substring(headEndIndex2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[SEO] HTML nach Manipulation, Länge:', baseHTML.length);
|
|
||||||
console.log('[SEO] Enthält Script-Tags nach Manipulation:', baseHTML.includes('<script'));
|
|
||||||
|
|
||||||
return baseHTML;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupSEORoutes(app, __dirname) {
|
export function setupSEORoutes(app, __dirname) {
|
||||||
// Pre-Rendering für SEO-relevante Routen (nur in Production)
|
|
||||||
// In Development wird die normale index.html verwendet
|
|
||||||
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
if (IS_PRODUCTION) {
|
if (IS_PRODUCTION) {
|
||||||
const distIndexPath = resolve(__dirname, '../docroot/dist/index.html');
|
const distIndexPath = resolve(__dirname, '../docroot/dist/index.html');
|
||||||
|
|
||||||
// Pre-Rendering für Hauptseite
|
Object.entries(seoData).forEach(([route, meta]) => {
|
||||||
app.get('/', (req, res) => {
|
app.get(route, (req, res) => {
|
||||||
const meta = seoData['/'];
|
const html = generateHTML(route, meta, __dirname);
|
||||||
const html = generateHTML('/', meta, __dirname);
|
|
||||||
if (html) {
|
if (html) {
|
||||||
res.send(html);
|
res.send(html);
|
||||||
} else {
|
return;
|
||||||
// Fallback: Verwende die gebaute index.html direkt (ohne Meta-Tag-Anpassung)
|
|
||||||
if (existsSync(distIndexPath)) {
|
|
||||||
res.sendFile(distIndexPath);
|
|
||||||
} else {
|
|
||||||
console.error('FEHLER: Gebaute index.html nicht gefunden:', distIndexPath);
|
|
||||||
res.status(500).send('Gebaute index.html nicht gefunden. Bitte führe "npm run build" aus.');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pre-Rendering für Partners-Seite
|
|
||||||
app.get('/partners', (req, res) => {
|
|
||||||
const meta = seoData['/partners'];
|
|
||||||
const html = generateHTML('/partners', meta, __dirname);
|
|
||||||
if (html) {
|
|
||||||
res.send(html);
|
|
||||||
} else {
|
|
||||||
// Fallback: Verwende die gebaute index.html direkt (ohne Meta-Tag-Anpassung)
|
|
||||||
if (existsSync(distIndexPath)) {
|
if (existsSync(distIndexPath)) {
|
||||||
res.sendFile(distIndexPath);
|
res.sendFile(distIndexPath);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.error('FEHLER: Gebaute index.html nicht gefunden:', distIndexPath);
|
console.error('FEHLER: Gebaute index.html nicht gefunden:', distIndexPath);
|
||||||
res.status(500).send('Gebaute index.html nicht gefunden. Bitte führe "npm run build" aus.');
|
res.status(500).send('Gebaute index.html nicht gefunden. Bitte führe "npm run build" aus.');
|
||||||
}
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// robots.txt
|
|
||||||
app.get('/robots.txt', (req, res) => {
|
app.get('/robots.txt', (req, res) => {
|
||||||
const robotsTxt = `User-agent: *
|
const robotsTxt = `User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /partners
|
Allow: /partners
|
||||||
|
Allow: /feedback
|
||||||
Disallow: /api/
|
Disallow: /api/
|
||||||
Disallow: /static/logs/
|
Disallow: /static/logs/
|
||||||
|
Disallow: /mockup-redesign
|
||||||
|
|
||||||
Sitemap: https://ypchat.net/sitemap.xml
|
Sitemap: ${SITE_URL}/sitemap.xml
|
||||||
`;
|
`;
|
||||||
res.type('text/plain');
|
res.type('text/plain');
|
||||||
res.send(robotsTxt);
|
res.send(robotsTxt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// sitemap.xml
|
|
||||||
app.get('/sitemap.xml', (req, res) => {
|
app.get('/sitemap.xml', (req, res) => {
|
||||||
|
const currentDate = new Date().toISOString().split('T')[0];
|
||||||
|
const urls = Object.entries(seoData)
|
||||||
|
.map(([route, meta]) => {
|
||||||
|
const priority = route === '/' ? '1.0' : '0.8';
|
||||||
|
const changefreq = route === '/' ? 'daily' : 'weekly';
|
||||||
|
return ` <url>
|
||||||
|
<loc>${meta.ogUrl}</loc>
|
||||||
|
<lastmod>${currentDate}</lastmod>
|
||||||
|
<changefreq>${changefreq}</changefreq>
|
||||||
|
<priority>${priority}</priority>
|
||||||
|
</url>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
${urls}
|
||||||
<loc>https://ypchat.net/</loc>
|
|
||||||
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
|
|
||||||
<changefreq>daily</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://ypchat.net/partners</loc>
|
|
||||||
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>`;
|
</urlset>`;
|
||||||
res.type('application/xml');
|
res.type('application/xml');
|
||||||
res.send(sitemap);
|
res.send(sitemap);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
132
server/routes.js
132
server/routes.js
@@ -7,6 +7,8 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getSessionStatus, getClientsMap, getSessionIdForSocket, extractSessionId } from './broadcast.js';
|
import { getSessionStatus, getClientsMap, getSessionIdForSocket, extractSessionId } from './broadcast.js';
|
||||||
|
import { verifyChatUser } from './chat-auth.js';
|
||||||
|
import { loadFeedback, saveFeedback, createFeedbackEntry } from './feedback-store.js';
|
||||||
|
|
||||||
// __dirname für ES-Module
|
// __dirname für ES-Module
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -55,6 +57,135 @@ export function setupRoutes(app, __dirname) {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/logout', (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessionId = req.sessionID;
|
||||||
|
const clientsMap = getClientsMap();
|
||||||
|
const client = clientsMap.get(sessionId);
|
||||||
|
|
||||||
|
if (client?.socket) {
|
||||||
|
try {
|
||||||
|
client.socket.disconnect(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Logout: Socket konnte nicht sauber getrennt werden:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
clientsMap.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.destroy((error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Logout: Session konnte nicht zerstört werden:', error);
|
||||||
|
return res.status(500).json({ success: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.clearCookie('connect.sid');
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout-Fehler:', error);
|
||||||
|
res.status(500).json({ success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/feedback', (req, res) => {
|
||||||
|
try {
|
||||||
|
const feedback = loadFeedback(__dirname)
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
res.json({
|
||||||
|
items: feedback,
|
||||||
|
admin: !!req.session.feedbackAdmin
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden des Feedbacks:', error);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Laden des Feedbacks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/feedback/admin-status', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
authenticated: !!req.session.feedbackAdmin,
|
||||||
|
username: req.session.feedbackAdmin?.username || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/feedback', (req, res) => {
|
||||||
|
try {
|
||||||
|
const ageValue = req.body.age === '' || req.body.age === null || req.body.age === undefined
|
||||||
|
? null
|
||||||
|
: Number.parseInt(req.body.age, 10);
|
||||||
|
|
||||||
|
const entry = createFeedbackEntry({
|
||||||
|
name: req.body.name,
|
||||||
|
age: Number.isNaN(ageValue) ? null : ageValue,
|
||||||
|
country: req.body.country,
|
||||||
|
gender: req.body.gender,
|
||||||
|
comment: req.body.comment
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!entry.comment) {
|
||||||
|
return res.status(400).json({ error: 'Kommentar ist erforderlich.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = loadFeedback(__dirname);
|
||||||
|
feedback.push(entry);
|
||||||
|
saveFeedback(__dirname, feedback);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, item: entry });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Speichern des Feedbacks:', error);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Speichern des Feedbacks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/feedback/admin-login', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
const auth = verifyChatUser(__dirname, username, password);
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
return res.status(401).json({ error: 'Login fehlgeschlagen.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.feedbackAdmin = {
|
||||||
|
username: auth.username
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({ success: true, username: auth.username });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Feedback-Admin-Login:', error);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Login' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/feedback/admin-logout', (req, res) => {
|
||||||
|
delete req.session.feedbackAdmin;
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/feedback/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.session.feedbackAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Nicht erlaubt.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = loadFeedback(__dirname);
|
||||||
|
const nextFeedback = feedback.filter((item) => item.id !== req.params.id);
|
||||||
|
|
||||||
|
if (nextFeedback.length === feedback.length) {
|
||||||
|
return res.status(404).json({ error: 'Eintrag nicht gefunden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
saveFeedback(__dirname, nextFeedback);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen des Feedbacks:', error);
|
||||||
|
res.status(500).json({ error: 'Fehler beim Löschen des Feedbacks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bild-Upload-Endpoint
|
// Bild-Upload-Endpoint
|
||||||
app.post('/api/upload-image', upload.single('image'), (req, res) => {
|
app.post('/api/upload-image', upload.single('image'), (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -385,4 +516,3 @@ export function setupRoutes(app, __dirname) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
update-ypchat.sh
Executable file
44
update-ypchat.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Single entry point for deployment/update.
|
||||||
|
# Default: regular update
|
||||||
|
# --init : first-time setup (service installation)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MODE="${1:-update}"
|
||||||
|
REPO_DIR="/home/torsten/singlechat"
|
||||||
|
TARGET_DIR="/opt/ypchat"
|
||||||
|
SERVICE_NAME="ypchat"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
APP_USER="www-data"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "SingleChat Update"
|
||||||
|
echo "Mode: ${MODE}"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
git fetch --all --prune
|
||||||
|
git pull
|
||||||
|
|
||||||
|
echo "Deploy nach ${TARGET_DIR}..."
|
||||||
|
sudo "$REPO_DIR/deploy-to-opt.sh"
|
||||||
|
|
||||||
|
if [[ "$MODE" == "--init" || "$MODE" == "init" ]]; then
|
||||||
|
echo "Initiales Setup: Service wird installiert..."
|
||||||
|
sudo "$REPO_DIR/install-service-ypchat.sh"
|
||||||
|
elif [[ ! -f "$SERVICE_FILE" ]]; then
|
||||||
|
echo "Service-Datei nicht gefunden, installiere Service einmalig..."
|
||||||
|
sudo "$REPO_DIR/install-service-ypchat.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installiere/aktualisiere App-Abhängigkeiten und baue Client..."
|
||||||
|
sudo -u "$APP_USER" bash -c "cd '$TARGET_DIR' && ./install.sh"
|
||||||
|
|
||||||
|
echo "Starte Service neu..."
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
sudo systemctl status "$SERVICE_NAME" --no-pager -l
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Update erfolgreich abgeschlossen."
|
||||||
Reference in New Issue
Block a user