Compare commits

...

18 Commits

Author SHA1 Message Date
Torsten Schulz (local)
e8e1eb4e25 Enhance user statistics reporting in broadcast.js
- Updated the command table to include the last login timestamp for each user, providing more detailed insights into user engagement.
- Modified the structure of the statistics display to improve clarity and usefulness of the data presented.

These changes enhance the depth of user engagement statistics and improve overall reporting accuracy.
2026-03-19 15:45:33 +01:00
Torsten Schulz (local)
c847f249ba Enhance daily statistics reporting in broadcast.js
- Updated the statistics for 'today' to include a list of unique users who logged in, improving the detail and usefulness of the data presented.
- Modified the command table structure to display metrics more clearly, including the date, total logins, and unique users logged in today.

These changes enhance the clarity and depth of user engagement statistics.
2026-03-19 15:42:49 +01:00
Torsten Schulz (local)
8319b43835 Add HeaderAdBanner component to ChatView, FeedbackView, and PartnersView
- Integrated HeaderAdBanner into ChatView.vue, FeedbackView.vue, and PartnersView.vue to enhance advertising capabilities.
- Updated PartnersView.vue to include a new app branding structure for improved visual hierarchy.

These changes collectively improve the user interface and advertising integration across multiple views.
2026-03-19 15:25:42 +01:00
Torsten Schulz (local)
47373a27af Enhance SEO and feedback features across the application
- Updated index.html with improved meta tags for SEO, including author and theme color.
- Added a feedback dialog in ImprintContainer.vue for user feedback submission.
- Refactored LoginForm.vue to utilize a utility for cookie management, simplifying profile persistence.
- Introduced new routes and schemas for feedback in the router and server, enhancing SEO and user experience.
- Improved ChatView.vue with better error handling and command table display.
- Implemented feedback API endpoints in server routes for managing user feedback submissions and admin access.

These changes collectively improve the application's SEO, user interaction, and feedback management capabilities.
2026-03-19 15:21:54 +01:00
Torsten Schulz (local)
0205352ae9 Enhance UI and functionality across multiple components
- Updated styles in style.css to improve overall design consistency and introduced CSS variables for better theming.
- Refined ChatWindow.vue with improved no-conversation styling and adjusted image borders for a cleaner look.
- Enhanced HistoryView.vue and InboxView.vue with new panel styles for better user experience and readability.
- Revamped LoginForm.vue to provide a more engaging user interface with a landing page layout and cookie-based profile persistence.
- Improved MenuBar.vue and SearchView.vue with active state indicators and refined item displays for better navigation.
- Added logout functionality in chat store and server routes to manage user sessions effectively.
- Introduced a new mockup view route for design previews.

These changes collectively enhance the user experience and visual appeal of the application.
2026-03-19 15:01:59 +01:00
Torsten Schulz (local)
8f3cbc16b8 Refactor command help responses in broadcast.js to use structured command tables for improved clarity and organization. Updated help messages for statistics and available commands to enhance user experience. 2026-03-19 14:16:40 +01:00
Torsten Schulz (local)
6d17afe3a1 Remove outdated update.sh script as it has been replaced by update-ypchat.sh for deployment and service management. This change streamlines the update process and consolidates functionality into a single script. 2026-03-19 14:12:51 +01:00
Torsten Schulz (local)
1e092a7232 Add update-ypchat.sh script for deployment and service management
- Introduced a new script, update-ypchat.sh, to streamline the deployment and update process for the ypchat service.
- Replaced the previous update.sh script, consolidating commands for fetching updates, deploying, and managing the service.
- Added support for initial setup and service installation if not already present.
- Ensured proper execution of application dependencies and service restart with appropriate user permissions.
2026-03-19 14:12:44 +01:00
Torsten Schulz (local)
e3928e0b65 Start timeout timer on fresh login in chat store to enhance session management. 2026-03-19 14:08:06 +01:00
Torsten Schulz (local)
6158c9d3c0 Update update.sh to run deployment scripts with sudo
- Modified the update.sh script to prepend 'sudo' to deployment commands for elevated permissions.
- Ensured that all installation and update scripts are executed with the necessary privileges to avoid permission issues during execution.
2026-03-19 14:04:21 +01:00
Torsten Schulz (local)
39fd5e9290 Enhance command table functionality in ChatView component
- Improved command table rendering in ChatView.vue for better user experience.
- Added support for dynamic updates to command table based on WebSocket messages.
- Implemented styling adjustments for improved visual clarity.
- Refined logic for clearing command table state after use.
2026-03-19 14:03:03 +01:00
Torsten Schulz (local)
448f2ffb6f Add command table functionality to chat store and ChatView component
- Introduced `commandTable` state in chat store to manage command output.
- Implemented WebSocket listener for `commandTable` messages.
- Enhanced ChatView.vue to display command table with dynamic content and styling.
- Added `clearCommandTable` method to reset command table state.
- Updated server broadcast logic to send structured command table data for various statistics.
2026-03-19 14:00:08 +01:00
Torsten Schulz (local)
51040391e8 Refactor command handling in broadcast.js to streamline input processing. Moved command validation and parsing logic to ensure proper handling of user inputs during chat login and command execution. 2026-03-19 13:54:14 +01:00
Torsten Schulz (local)
bcb3b5b71f Implement login functionality in ChatInput and chat store. Update input handling to support username and password prompts, enhance message sending logic, and ensure proper command handling during login. Adjust broadcast logic to manage login states and provide appropriate feedback to users. 2026-03-19 13:48:09 +01:00
Torsten Schulz (local)
83110659db Refactor manual chunking in Vite configuration to improve code clarity and maintainability. The updated logic now checks for 'node_modules' and categorizes dependencies into 'vue-vendor' and 'socket-vendor' chunks accordingly. 2026-03-19 13:38:01 +01:00
Torsten Schulz (local)
c7ea33fb2c Upgrade @vitejs/plugin-vue to version 6.0.5 in package.json and package-lock.json, ensuring compatibility with updated node and peer dependencies. 2026-03-19 13:35:23 +01:00
Torsten Schulz (local)
527cea1261 Update dependencies and enhance ChatInput component functionality. Upgraded Vite to version 8.0.1, updated various package versions in package-lock.json, and improved user experience in ChatInput.vue by adding dynamic placeholder text and error handling for message sending without an active conversation. 2026-03-19 13:30:40 +01:00
Torsten Schulz (local)
aabf162f04 Enhance security by preventing the creation of default admin credentials in chat-users.json. Update chat.js to ensure command output does not pollute conversation history, and add chat-users.json to .gitignore to prevent tracking of sensitive user data. 2026-03-19 13:15:34 +01:00
35 changed files with 4817 additions and 1319 deletions

1
.gitignore vendored
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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 = '';
} }

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1,53 +1,73 @@
<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>
<label>{{ $t('label_nick') }}</label> <h2>Direkt in den Chat</h2>
<input v-model="nickname" type="text" required minlength="3" /> <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>
<div class="welcome-message" v-html="$t('welcome')"></div>
<div class="form-row"> </section>
<label>{{ $t('label_gender') }}</label>
<select v-model="gender" required> <section class="landing-login-card">
<option value="">{{ $t('label_gender') }}</option> <div class="landing-login-card-header">
<option value="F">{{ $t('gender_female') }}</option> <h3>Profil starten</h3>
<option value="M">{{ $t('gender_male') }}</option> <p>Wenige Angaben genügen für den Einstieg.</p>
<option value="P">{{ $t('gender_pair') }}</option>
<option value="TF">{{ $t('gender_trans_mf') }}</option>
<option value="TM">{{ $t('gender_trans_fm') }}</option>
</select>
</div> </div>
<div class="form-row"> <form class="landing-login-fields" @submit.prevent="handleSubmit">
<label>{{ $t('label_age') }}</label> <div class="landing-form-row">
<input v-model.number="age" type="number" required min="18" max="120" /> <label>{{ $t('label_nick') }}</label>
</div> <input v-model="nickname" type="text" required minlength="3" />
</div>
<div class="form-row">
<label>{{ $t('label_country') }}</label> <div class="landing-form-row">
<select v-model="country" required> <label>{{ $t('label_gender') }}</label>
<option value="">{{ $t('label_country') }}</option> <select v-model="gender" required>
<option v-for="(code, name) in countries" :key="code" :value="name"> <option value="">{{ $t('label_gender') }}</option>
{{ name }} <option value="F">{{ $t('gender_female') }}</option>
</option> <option value="M">{{ $t('gender_male') }}</option>
</select> <option value="P">{{ $t('gender_pair') }}</option>
</div> <option value="TF">{{ $t('gender_trans_mf') }}</option>
<option value="TM">{{ $t('gender_trans_fm') }}</option>
<div class="form-row"> </select>
<button type="submit">{{ $t('button_start_chat') }}</button> </div>
</div>
</form> <div class="landing-form-row">
<label>{{ $t('label_age') }}</label>
<div class="welcome-message" v-html="$t('welcome')"></div> <input v-model.number="age" type="number" required min="18" max="120" />
</div>
<div class="landing-form-row">
<label>{{ $t('label_country') }}</label>
<select v-model="country" required>
<option value="">{{ $t('label_country') }}</option>
<option v-for="(code, name) in countries" :key="code" :value="name">
{{ name }}
</option>
</select>
</div>
<div class="landing-form-row landing-form-row-submit">
<button type="submit">{{ $t('button_start_chat') }}</button>
</div>
</form>
</section>
</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>

View File

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

View File

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

View File

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

View File

@@ -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,50 +116,74 @@ const router = createRouter({
routes routes
}); });
// Meta-Tags dynamisch aktualisieren basierend auf Route function updateMetaTag(name, content, attribute = 'name') {
let element = document.querySelector(`meta[${attribute}="${name}"]`);
if (!element) {
element = document.createElement('meta');
element.setAttribute(attribute, name);
document.head.appendChild(element);
}
element.setAttribute('content', content);
}
function updateLinkTag(rel, href) {
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);
}
function updateJsonLd(schema) {
let element = document.querySelector('#seo-json-ld');
if (!element) {
element = document.createElement('script');
element.id = 'seo-json-ld';
element.type = 'application/ld+json';
document.head.appendChild(element);
}
element.textContent = schema ? JSON.stringify(schema) : '';
}
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
// Aktualisiere Title const meta = to.meta || {};
if (to.meta.title) { const pageUrl = `${SITE_URL}${to.path}`;
document.title = to.meta.title; const title = meta.title || 'SingleChat';
} const description = meta.description || '';
const keywords = meta.keywords || '';
// Aktualisiere Meta-Tags const ogTitle = meta.ogTitle || title;
const updateMetaTag = (name, content, attribute = 'name') => { const ogDescription = meta.ogDescription || description;
let element = document.querySelector(`meta[${attribute}="${name}"]`); const ogType = meta.ogType || 'website';
if (!element) { const image = meta.image || DEFAULT_IMAGE;
element = document.createElement('meta'); const robots = meta.robots || 'index, follow';
element.setAttribute(attribute, name);
document.head.appendChild(element); document.title = title;
}
element.setAttribute('content', content); updateMetaTag('description', description);
}; updateMetaTag('keywords', keywords);
updateMetaTag('robots', robots);
if (to.meta.description) { updateMetaTag('theme-color', '#2f6f46');
updateMetaTag('description', to.meta.description);
updateMetaTag('og:description', to.meta.description, 'property'); updateMetaTag('og:title', ogTitle, 'property');
updateMetaTag('twitter:description', to.meta.description); updateMetaTag('og:description', ogDescription, 'property');
} updateMetaTag('og:type', ogType, 'property');
updateMetaTag('og:url', pageUrl, 'property');
if (to.meta.keywords) { updateMetaTag('og:image', image, 'property');
updateMetaTag('keywords', to.meta.keywords); updateMetaTag('og:site_name', 'SingleChat', 'property');
} updateMetaTag('og:locale', 'de_DE', 'property');
// Aktualisiere Open Graph URL updateMetaTag('twitter:card', robots.startsWith('noindex') ? 'summary' : 'summary_large_image');
const ogUrl = `https://ypchat.net${to.path}`; updateMetaTag('twitter:title', ogTitle);
updateMetaTag('og:url', ogUrl, 'property'); updateMetaTag('twitter:description', ogDescription);
updateMetaTag('canonical', ogUrl, 'rel'); updateMetaTag('twitter:image', image);
// Aktualisiere Canonical Link updateLinkTag('canonical', pageUrl);
let canonicalLink = document.querySelector('link[rel="canonical"]'); updateJsonLd(meta.schema || null);
if (!canonicalLink) {
canonicalLink = document.createElement('link');
canonicalLink.setAttribute('rel', 'canonical');
document.head.appendChild(canonicalLink);
}
canonicalLink.setAttribute('href', ogUrl);
next(); next();
}); });
export default router; export default router;

View File

@@ -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';
errorMessage.value = lines.join(' | ');
setTimeout(() => { if (kind === 'loginPromptUsername') {
errorMessage.value = null; awaitingLoginUsername.value = true;
}, 5000); awaitingLoginPassword.value = false;
break; } else if (kind === 'loginPromptPassword') {
} awaitingLoginUsername.value = false;
const timestamp = new Date().toISOString(); awaitingLoginPassword.value = true;
for (const line of lines) { } else if (kind === 'loginSuccess' || kind === 'loginError' || kind === 'loginAbort' || kind === 'loginLogout') {
messages.value.push({ awaitingLoginUsername.value = false;
from: 'System', awaitingLoginPassword.value = false;
message: String(line),
timestamp,
self: false,
isImage: false
});
} }
// Command-Ausgaben immer global anzeigen, nicht im Chatverlauf.
errorMessage.value = lines.join(' | ');
setTimeout(() => {
errorMessage.value = null;
}, 5000);
break;
}
case 'commandTable': {
const title = data.title || 'Ausgabe';
const columns = Array.isArray(data.columns) ? data.columns : [];
const rows = Array.isArray(data.rows) ? data.rows : [];
commandTable.value = { title, columns, rows };
// Tabelle ist persistent; temporäre Fehlermeldung löschen
errorMessage.value = null;
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
}; };
}); });

View File

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

View 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('; ');
}

View File

@@ -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.country }}</span>
<span v-if="currentUserInfo">{{ currentUserInfo.age }}</span> <span v-if="currentUserInfo">{{ currentUserInfo.age }} · {{ currentUserInfo.gender }}</span>
<span v-if="currentUserInfo">{{ currentUserInfo.country }}</span> </div>
</div> </div>
</div> </div>
<ChatWindow v-if="!chatStore.errorMessage" /> <ChatWindow />
<ChatInput v-if="chatStore.currentConversation && !chatStore.errorMessage" />
</div> </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>

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

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
[
{
"username": "admin",
"passwordHash": "sha256:REPLACE_WITH_REAL_HASH",
"rights": ["stat", "kick"]
}
]

11
logs/feedback.json Normal file
View 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
View File

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

View File

@@ -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
View 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
View 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() : ''
};
}

View File

@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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); html = html.replace(/<title>.*?<\/title>/, `<title>${escapeHtml(meta.title)}</title>`);
console.log('[SEO] Enthält Script-Tags:', baseHTML.includes('<script')); html = upsertMetaTag(html, 'description', meta.description);
html = upsertMetaTag(html, 'keywords', meta.keywords);
// Ersetze Meta-Tags in der gebauten HTML html = upsertMetaTag(html, 'robots', meta.robots);
baseHTML = baseHTML.replace(/<title>.*?<\/title>/, `<title>${meta.title}</title>`); 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); return;
} 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);
res.status(500).send('Gebaute index.html nicht gefunden. Bitte führe "npm run build" aus.');
} }
}
}); 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)) {
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.');
}
}
}); });
} }
// 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);
}); });
} }

View File

@@ -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);
@@ -54,6 +56,135 @@ export function setupRoutes(app, __dirname) {
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
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) => {
@@ -385,4 +516,3 @@ export function setupRoutes(app, __dirname) {
} }
}); });
} }

44
update-ypchat.sh Executable file
View 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."