Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4eae67612 | ||
|
|
891f52b0b8 | ||
|
|
725d3bcff4 | ||
|
|
69f69cf172 | ||
|
|
68c8f9a05a | ||
|
|
2b8733dea0 | ||
|
|
317eed5ea6 | ||
|
|
a503edb220 | ||
|
|
a80c14223b | ||
|
|
8c9c4eb159 | ||
|
|
68dfba38df | ||
|
|
b51ad2ff1a | ||
|
|
5613e5d48e | ||
|
|
09b998ea75 | ||
|
|
74a8a59083 | ||
|
|
f2c64281dd | ||
|
|
ca40b1efb9 | ||
|
|
3c051ec49d | ||
|
|
b268abb7d3 | ||
|
|
c7793dcb9d | ||
|
|
95fd6405be | ||
|
|
e881979da3 | ||
|
|
8ec713297a | ||
|
|
4aef034aa6 | ||
|
|
b120e5df45 | ||
|
|
68c074e9da | ||
|
|
20910e5cbf | ||
|
|
ff6aff25e8 | ||
|
|
2f5c06fb52 | ||
|
|
6893158926 | ||
|
|
038797a5da | ||
|
|
25a79230a8 | ||
|
|
0182db69b5 | ||
|
|
794e3fd74a | ||
|
|
d874682764 | ||
|
|
771d0d06f3 | ||
|
|
9df9a808bf | ||
|
|
5da78c926d | ||
|
|
120ffaaf2c | ||
|
|
50511f11ac | ||
|
|
d69ac28bb3 | ||
|
|
7a65c58214 | ||
|
|
1a8177430d | ||
|
|
0ebb61515d | ||
|
|
dede11d22b | ||
|
|
4b96b95bff | ||
|
|
89fb296564 | ||
|
|
301dce4c97 | ||
|
|
b66bab48bd |
66
.dockerignore
Normal file
66
.dockerignore
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Next.js build outputs
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
build
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# Git (NICHT ausschließen - wird für Version-Extraktion benötigt!)
|
||||||
|
# .git wird benötigt für: git describe --tags --always
|
||||||
|
# .gitignore
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Hördle specific - WICHTIG: Upload-Dateien NICHT ins Image kopieren!
|
||||||
|
# Diese werden als Volume gemountet und sollten nicht im Image sein
|
||||||
|
/public/uploads/*
|
||||||
|
!/public/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Database files - werden als Volume gemountet
|
||||||
|
/data/*
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Backups
|
||||||
|
/backups
|
||||||
|
|
||||||
|
# Docker files (nicht notwendig im Image)
|
||||||
|
docker-compose*.yml
|
||||||
|
Dockerfile*
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
|
# Scripts die nicht im Container gebraucht werden
|
||||||
|
scripts/fix-*.sh
|
||||||
|
scripts/check-*.sh
|
||||||
|
scripts/debug-*.sh
|
||||||
|
scripts/quick-*.sh
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.log
|
||||||
|
|
||||||
106
.env.example
Normal file
106
.env.example
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ============================================
|
||||||
|
# Hördle Environment Variables
|
||||||
|
# ============================================
|
||||||
|
# Kopiere diese Datei zu .env und passe die Werte an deine Umgebung an:
|
||||||
|
# cp .env.example .env
|
||||||
|
#
|
||||||
|
# WICHTIG: Die .env-Datei sollte niemals in Git committed werden!
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Build-Time Variables (NEXT_PUBLIC_*)
|
||||||
|
# ============================================
|
||||||
|
# Diese Variablen werden beim Build-Zeitpunkt in die Next.js-App eingebettet.
|
||||||
|
# Nach dem Build können sie nicht mehr geändert werden (ohne Rebuild).
|
||||||
|
|
||||||
|
# App-Name (wird in Browser-Tab, PWA, etc. verwendet)
|
||||||
|
NEXT_PUBLIC_APP_NAME=Hördle
|
||||||
|
|
||||||
|
# App-Beschreibung (für SEO, PWA, etc.)
|
||||||
|
NEXT_PUBLIC_APP_DESCRIPTION=Daily music guessing game - Guess the song from short audio clips
|
||||||
|
|
||||||
|
# Hauptdomain (ohne https://)
|
||||||
|
NEXT_PUBLIC_DOMAIN=hoerdle.de
|
||||||
|
|
||||||
|
# Twitter/X Handle (für Meta-Tags)
|
||||||
|
NEXT_PUBLIC_TWITTER_HANDLE=@hoerdle
|
||||||
|
|
||||||
|
# Plausible Analytics - Domain
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=hoerdle.de
|
||||||
|
|
||||||
|
# Plausible Analytics - Script-URL (selbst gehostet oder extern)
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.example.com/js/script.js
|
||||||
|
|
||||||
|
# Theme-Farbe (für Browser-UI, PWA, etc.)
|
||||||
|
NEXT_PUBLIC_THEME_COLOR=#000000
|
||||||
|
|
||||||
|
# Hintergrundfarbe (für PWA, etc.)
|
||||||
|
NEXT_PUBLIC_BACKGROUND_COLOR=#ffffff
|
||||||
|
|
||||||
|
# Credits im Footer aktivieren (true/false)
|
||||||
|
NEXT_PUBLIC_CREDITS_ENABLED=true
|
||||||
|
|
||||||
|
# Credits-Text (vor dem Link)
|
||||||
|
NEXT_PUBLIC_CREDITS_TEXT=Vibe coded with ☕ and 🍺 by
|
||||||
|
|
||||||
|
# Credits-Link-Text
|
||||||
|
NEXT_PUBLIC_CREDITS_LINK_TEXT=@yourhandle@server.social
|
||||||
|
|
||||||
|
# Credits-Link-URL
|
||||||
|
NEXT_PUBLIC_CREDITS_LINK_URL=https://server.social/@yourhandle
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Runtime Variables
|
||||||
|
# ============================================
|
||||||
|
# Diese Variablen können zur Laufzeit geändert werden (benötigen keinen Rebuild).
|
||||||
|
|
||||||
|
# Datenbank-URL (SQLite für lokale/kleine Deployments)
|
||||||
|
# Format: file:/path/to/database.db
|
||||||
|
DATABASE_URL=file:/app/data/prod.db
|
||||||
|
|
||||||
|
# Admin-Passwort (bcrypt Hash)
|
||||||
|
# Generiere einen Hash mit: node scripts/hash-password.js dein_passwort
|
||||||
|
# In docker-compose.yml müssen $ als $$ escaped werden!
|
||||||
|
ADMIN_PASSWORD=$2b$10$SHOt9G1qUNIvHoWre7499.eEtp5PtOII0daOQGNV.dhDEuPmOUdsq
|
||||||
|
|
||||||
|
# Zeitzone (für tägliche Puzzle-Rotation)
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Optional: Gotify Integration
|
||||||
|
# ============================================
|
||||||
|
# Für Benachrichtigungen (z.B. Fehler-Alerts)
|
||||||
|
|
||||||
|
# Gotify Server URL
|
||||||
|
GOTIFY_URL=https://gotify.example.com
|
||||||
|
|
||||||
|
# Gotify App Token
|
||||||
|
GOTIFY_APP_TOKEN=your_gotify_app_token_here
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Optional: OpenRouter Integration
|
||||||
|
# ============================================
|
||||||
|
# Für AI-Features (falls vorhanden)
|
||||||
|
|
||||||
|
# OpenRouter API Key
|
||||||
|
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Caddy Reverse Proxy (Optional - Production)
|
||||||
|
# ============================================
|
||||||
|
# Nur benötigt, wenn Caddy für SSL/TLS verwendet wird.
|
||||||
|
|
||||||
|
# GoDaddy API Key (für DNS-01 Challenge bei Wildcard-Zertifikaten)
|
||||||
|
# Siehe CADDY_SETUP.md für Anleitung zur Erstellung
|
||||||
|
GODADDY_API_KEY=your_godaddy_api_key_here
|
||||||
|
|
||||||
|
# GoDaddy API Secret
|
||||||
|
GODADDY_API_SECRET=your_godaddy_api_secret_here
|
||||||
|
|
||||||
|
# Email für Let's Encrypt Benachrichtigungen (optional)
|
||||||
|
CADDY_EMAIL=admin@hoerdle.de
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Build-Time Overrides
|
||||||
|
# ============================================
|
||||||
|
# Optional: Spezifische Version beim Build setzen
|
||||||
|
# APP_VERSION=v1.0.0
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -50,3 +51,4 @@ next-env.d.ts
|
|||||||
/data
|
/data
|
||||||
.release-years-migrated
|
.release-years-migrated
|
||||||
.covers-migrated
|
.covers-migrated
|
||||||
|
docker-compose.yml
|
||||||
|
|||||||
289
CADDY_SETUP.md
Normal file
289
CADDY_SETUP.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# Caddy-Setup für Hördle
|
||||||
|
|
||||||
|
Diese Anleitung erklärt, wie du Caddy als Reverse-Proxy mit automatischen Let's Encrypt Wildcard-Zertifikaten für die Domains `hoerdle.de` und `hördle.de` (xn--hrdle-jua.de) einrichtest.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Caddy übernimmt folgende Aufgaben:
|
||||||
|
- Automatische SSL/TLS-Zertifikate via Let's Encrypt
|
||||||
|
- Wildcard-Zertifikate für beide Domains (inkl. Subdomains)
|
||||||
|
- Reverse Proxy zu deinem Hördle-Container
|
||||||
|
- HTTP zu HTTPS Redirect
|
||||||
|
- Optimierte Einstellungen für Audio-Streaming und Uploads
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
1. Docker und Docker Compose installiert
|
||||||
|
2. Zugriff auf deine GoDaddy Domain-Verwaltung
|
||||||
|
3. Ports 80 und 443 müssen frei sein (Caddy übernimmt diese)
|
||||||
|
|
||||||
|
## Schritt 1: GoDaddy DNS-API-Zugangsdaten erstellen
|
||||||
|
|
||||||
|
Für Wildcard-Zertifikate benötigt Caddy DNS-01 Challenge, was API-Zugriff auf dein GoDaddy-Konto erfordert.
|
||||||
|
|
||||||
|
### GoDaddy API-Keys erstellen
|
||||||
|
|
||||||
|
1. Gehe zu [GoDaddy Developer Portal](https://developer.godaddy.com/)
|
||||||
|
2. Melde dich mit deinem GoDaddy-Konto an
|
||||||
|
3. Klicke auf **"Keys"** in der Navigation
|
||||||
|
4. Klicke auf **"Create New API Key"**
|
||||||
|
5. Fülle das Formular aus:
|
||||||
|
- **Key Name**: z.B. "Hördle Caddy DNS"
|
||||||
|
- **Environment**: Production (für echte Domains)
|
||||||
|
6. Klicke auf **"Create"**
|
||||||
|
7. **Wichtig**: Kopiere dir den **API Key** und das **API Secret** - das Secret wird nur einmal angezeigt!
|
||||||
|
|
||||||
|
### Alternative: Manuelle DNS-TXT-Records (ohne API)
|
||||||
|
|
||||||
|
Wenn du keine API-Keys verwenden möchtest, kannst du die DNS-TXT-Records manuell setzen. **Hinweis**: Dies ist nur für die initiale Zertifikatsanfrage möglich, nicht für automatische Erneuerungen.
|
||||||
|
|
||||||
|
Siehe Abschnitt "Manuelle DNS-Konfiguration (ohne API)" weiter unten.
|
||||||
|
|
||||||
|
## Schritt 2: Environment-Variablen konfigurieren
|
||||||
|
|
||||||
|
Erstelle eine `.env`-Datei im Projektverzeichnis (oder erweitere die bestehende):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GoDaddy API-Credentials für DNS-01 Challenge
|
||||||
|
GODADDY_API_KEY=your_api_key_here
|
||||||
|
GODADDY_API_SECRET=your_api_secret_here
|
||||||
|
|
||||||
|
# Optional: Email für Let's Encrypt Benachrichtigungen
|
||||||
|
CADDY_EMAIL=markus@hoerdle.de
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig**: Die `.env`-Datei sollte nicht in Git committed werden (sollte bereits in `.gitignore` sein).
|
||||||
|
|
||||||
|
## Schritt 3: Docker-Netzwerk erstellen
|
||||||
|
|
||||||
|
Caddy und Hördle müssen im gleichen Docker-Netzwerk kommunizieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prüfe, ob das Netzwerk bereits existiert
|
||||||
|
docker network ls | grep hoerdle
|
||||||
|
|
||||||
|
# Falls das Netzwerk bereits existiert, aber falsche Labels hat:
|
||||||
|
# 1. Stoppe alle Container, die das Netzwerk nutzen
|
||||||
|
docker compose -f docker-compose.yml down
|
||||||
|
|
||||||
|
# 2. Lösche das alte Netzwerk (falls keine Container mehr dranhängen)
|
||||||
|
docker network rm hoerdle_default
|
||||||
|
|
||||||
|
# 3. Erstelle das Netzwerk neu
|
||||||
|
docker network create hoerdle_default
|
||||||
|
|
||||||
|
# Falls das Netzwerk nicht existiert, erstelle es:
|
||||||
|
docker network create hoerdle_default
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis**: Die docker-compose.caddy.yml ist so konfiguriert, dass sie das Netzwerk als externes Netzwerk nutzt. Das bedeutet, dass das Netzwerk bereits existieren muss, bevor Caddy gestartet wird.
|
||||||
|
|
||||||
|
## Schritt 4: Caddy starten
|
||||||
|
|
||||||
|
### Option A: Mit docker-compose (Empfohlen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Starte Hördle + Caddy zusammen
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||||
|
|
||||||
|
# Nur Caddy starten (wenn Hördle bereits läuft)
|
||||||
|
docker compose -f docker-compose.caddy.yml --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Nur Caddy starten (Hördle läuft bereits)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.caddy.yml --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schritt 5: DNS-Konfiguration in GoDaddy
|
||||||
|
|
||||||
|
### Automatisch (mit API-Keys)
|
||||||
|
|
||||||
|
Wenn du API-Keys konfiguriert hast, wird Caddy automatisch die benötigten DNS-TXT-Records erstellen. Keine manuellen DNS-Änderungen nötig!
|
||||||
|
|
||||||
|
### Manuell (ohne API-Keys)
|
||||||
|
|
||||||
|
Wenn du die API-Keys nicht verwenden möchtest, musst du die DNS-TXT-Records manuell setzen:
|
||||||
|
|
||||||
|
#### Für hoerdle.de:
|
||||||
|
|
||||||
|
1. Gehe zu deinem [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/YOUR_DOMAIN/dns)
|
||||||
|
2. Für jedes Wildcard-Zertifikat benötigst du einen TXT-Record:
|
||||||
|
- **Typ**: TXT
|
||||||
|
- **Name**: `_acme-challenge`
|
||||||
|
- **Wert**: (wird von Let's Encrypt generiert - siehe Caddy-Logs)
|
||||||
|
- **TTL**: 600 (10 Minuten)
|
||||||
|
|
||||||
|
**Wichtig**: Für Wildcard-Zertifikate brauchst du:
|
||||||
|
- Einen TXT-Record für `_acme-challenge.hoerdle.de` (Domain selbst)
|
||||||
|
- Einen TXT-Record für `_acme-challenge.*.hoerdle.de` (Wildcard)
|
||||||
|
|
||||||
|
#### Für hördle.de (xn--hrdle-jua.de):
|
||||||
|
|
||||||
|
Das gleiche Vorgehen für die Punycode-Domain:
|
||||||
|
- `_acme-challenge.xn--hrdle-jua.de`
|
||||||
|
- `_acme-challenge.*.xn--hrdle-jua.de`
|
||||||
|
|
||||||
|
**Hinweis**: Die manuelle Methode funktioniert nur für die initiale Zertifikatsanfrage. Für automatische Erneuerungen benötigst du die API-Keys.
|
||||||
|
|
||||||
|
## Schritt 6: Prüfen, ob alles funktioniert
|
||||||
|
|
||||||
|
### Caddy-Logs ansehen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f hoerdle-caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
Du solltest sehen:
|
||||||
|
- Caddy startet erfolgreich
|
||||||
|
- Let's Encrypt-Zertifikate werden angefordert
|
||||||
|
- Zertifikate sind gültig
|
||||||
|
|
||||||
|
### Zertifikate prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prüfe Zertifikate im Browser
|
||||||
|
# Öffne: https://hoerdle.de
|
||||||
|
# Öffne: https://hördle.de
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder via Command-Line:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prüfe Zertifikat für hoerdle.de
|
||||||
|
openssl s_client -connect hoerdle.de:443 -servername hoerdle.de < /dev/null 2>/dev/null | openssl x509 -noout -text | grep "Subject:"
|
||||||
|
|
||||||
|
# Prüfe Zertifikat für hördle.de
|
||||||
|
openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de < /dev/null 2>/dev/null | openssl x509 -noout -text | grep "Subject:"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Caddy startet nicht
|
||||||
|
|
||||||
|
**Problem**: Container stoppt sofort nach Start.
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||||
|
2. Prüfe Caddyfile-Syntax: `docker run --rm -v $(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile`
|
||||||
|
3. Prüfe, ob Ports 80/443 frei sind: `sudo netstat -tlnp | grep -E ':80|:443'`
|
||||||
|
|
||||||
|
### Zertifikate werden nicht erstellt
|
||||||
|
|
||||||
|
**Problem**: Let's Encrypt-Zertifikate werden nicht angefordert.
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
1. Prüfe GoDaddy API-Credentials in `.env`
|
||||||
|
2. Prüfe Caddy-Logs für DNS-Challenge-Fehler
|
||||||
|
3. Stelle sicher, dass die Domains korrekt auf deinen Server zeigen (A-Records)
|
||||||
|
4. Bei manueller DNS-Konfiguration: Prüfe, ob TXT-Records korrekt gesetzt sind
|
||||||
|
|
||||||
|
### DNS-Challenge schlägt fehl
|
||||||
|
|
||||||
|
**Problem**: DNS-01 Challenge kann DNS-Records nicht erstellen.
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
1. Prüfe GoDaddy API-Permissions
|
||||||
|
2. Stelle sicher, dass API-Keys Production-Keys sind (nicht Development)
|
||||||
|
3. Prüfe Domain-Ownership in GoDaddy
|
||||||
|
4. Warte einige Minuten - DNS-Propagierung kann dauern
|
||||||
|
|
||||||
|
### Audio-Dateien funktionieren nicht
|
||||||
|
|
||||||
|
**Problem**: MP3-Dateien werden nicht korrekt gestreamt.
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy | grep -i range`
|
||||||
|
2. Prüfe, ob Range-Header weitergegeben werden (Browser DevTools → Network)
|
||||||
|
3. Stelle sicher, dass der `/uploads/` Handle korrekt konfiguriert ist
|
||||||
|
|
||||||
|
### Container können nicht kommunizieren
|
||||||
|
|
||||||
|
**Problem**: Caddy kann den hoerdle-Container nicht erreichen.
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
1. Prüfe, ob beide Container im gleichen Netzwerk sind:
|
||||||
|
```bash
|
||||||
|
docker network inspect hoerdle_default
|
||||||
|
```
|
||||||
|
2. Prüfe, ob hoerdle-Container läuft: `docker ps | grep hoerdle`
|
||||||
|
3. Teste Verbindung von Caddy zu Hördle:
|
||||||
|
```bash
|
||||||
|
docker exec hoerdle-caddy wget -O- http://hoerdle:3000/api/health
|
||||||
|
```
|
||||||
|
**Hinweis**: Der Container-Port ist 3000 (nicht 3010, das ist nur der Host-Port).
|
||||||
|
|
||||||
|
### Netzwerk-Warnung beim Deployment
|
||||||
|
|
||||||
|
**Problem**: Warnung `network hoerdle_default was found but has incorrect label`
|
||||||
|
|
||||||
|
**Erklärung**: Diese Warnung ist **harmlos** und kann ignoriert werden. Docker Compose funktioniert trotzdem einwandfrei. Sie entsteht, wenn das Netzwerk bereits existiert, aber nicht von Docker Compose erstellt wurde.
|
||||||
|
|
||||||
|
**Optional: Warnung beheben** (nur wenn sie stört):
|
||||||
|
```bash
|
||||||
|
# Reparatur-Skript ausführen (stoppt Container kurz)
|
||||||
|
./scripts/fix-network.sh
|
||||||
|
|
||||||
|
# Danach Container neu starten
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis**: Das Reparatur-Skript stoppt alle Container kurz, die das Netzwerk nutzen. In Produktion sollte dies außerhalb der Hauptnutzungszeit erfolgen.
|
||||||
|
|
||||||
|
## Deployment-Workflow
|
||||||
|
|
||||||
|
### Caddy nur in Produktion aktivieren
|
||||||
|
|
||||||
|
Die `docker-compose.caddy.yml` verwendet das `production`-Profile. Um Caddy zu aktivieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mit Production-Profile
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||||
|
|
||||||
|
# Ohne Caddy (nur Hördle)
|
||||||
|
docker compose -f docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull neues Caddy-Image
|
||||||
|
docker compose -f docker-compose.caddy.yml pull
|
||||||
|
|
||||||
|
# Restart Caddy-Container
|
||||||
|
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caddy-Konfiguration ändern
|
||||||
|
|
||||||
|
Nach Änderungen am Caddyfile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Caddyfile validieren
|
||||||
|
docker run --rm -v $(pwd)/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
|
# Caddy neu laden (ohne Downtime)
|
||||||
|
docker compose -f docker-compose.caddy.yml --profile production exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
### API-Keys schützen
|
||||||
|
|
||||||
|
- **Niemals** API-Keys in Git committen
|
||||||
|
- Verwende `.env`-Dateien (sollten in `.gitignore` sein)
|
||||||
|
- Setze minimale Berechtigungen für API-Keys in GoDaddy
|
||||||
|
- Rotiere API-Keys regelmäßig
|
||||||
|
|
||||||
|
### Firewall
|
||||||
|
|
||||||
|
Stelle sicher, dass nur Ports 80 und 443 öffentlich erreichbar sind. Port 3010 (Hördle) sollte nicht öffentlich erreichbar sein.
|
||||||
|
|
||||||
|
## Weitere Ressourcen
|
||||||
|
|
||||||
|
- [Caddy Dokumentation](https://caddyserver.com/docs/)
|
||||||
|
- [Caddy DNS-Provider](https://caddyserver.com/docs/modules/tls.dns)
|
||||||
|
- [GoDaddy API Dokumentation](https://developer.godaddy.com/doc/endpoint/domains)
|
||||||
|
- [Let's Encrypt Wildcard-Zertifikate](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge)
|
||||||
|
|
||||||
54
Caddyfile
Normal file
54
Caddyfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Caddy-Konfiguration für Hördle
|
||||||
|
# Root-Domains: hoerdle.de und hördle.de (xn--hrdle-jua.de)
|
||||||
|
# Hinweis: Diese Konfiguration funktioniert nur für Root-Domains, nicht für Subdomains
|
||||||
|
# Für Subdomains wären Wildcard-Zertifikate mit DNS-01 Challenge nötig
|
||||||
|
|
||||||
|
# Domain 1: hoerdle.de (ASCII)
|
||||||
|
hoerdle.de {
|
||||||
|
# TLS mit automatischer HTTP-01 Challenge (funktioniert nur für Root-Domain)
|
||||||
|
# Caddy verwendet automatisch Let's Encrypt
|
||||||
|
|
||||||
|
# Upload-Limit: 50MB (wie in nginx.conf.example)
|
||||||
|
request_body {
|
||||||
|
max_size 50MB
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse Proxy zu hoerdle Container
|
||||||
|
reverse_proxy hoerdle:3000 {
|
||||||
|
# HTTP/1.1 für WebSocket Support
|
||||||
|
transport http {
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP zu HTTPS Redirect
|
||||||
|
@http {
|
||||||
|
protocol http
|
||||||
|
}
|
||||||
|
redir @http https://{host}{uri} permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
# Domain 2: hördle.de (Punycode: xn--hrdle-jua.de)
|
||||||
|
xn--hrdle-jua.de {
|
||||||
|
# TLS mit automatischer HTTP-01 Challenge (funktioniert nur für Root-Domain)
|
||||||
|
# Caddy verwendet automatisch Let's Encrypt
|
||||||
|
|
||||||
|
# Upload-Limit: 50MB
|
||||||
|
request_body {
|
||||||
|
max_size 50MB
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse Proxy zu hoerdle Container
|
||||||
|
reverse_proxy hoerdle:3000 {
|
||||||
|
# HTTP/1.1 für WebSocket Support
|
||||||
|
transport http {
|
||||||
|
versions 1.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP zu HTTPS Redirect
|
||||||
|
@http {
|
||||||
|
protocol http
|
||||||
|
}
|
||||||
|
redir @http https://{host}{uri} permanent
|
||||||
|
}
|
||||||
@@ -82,3 +82,35 @@ docker ps
|
|||||||
```
|
```
|
||||||
|
|
||||||
Look for the "healthy" status in the STATUS column.
|
Look for the "healthy" status in the STATUS column.
|
||||||
|
|
||||||
|
## Caddy Reverse Proxy (Optional - Production)
|
||||||
|
|
||||||
|
For production deployments with automatic SSL/TLS certificates, Caddy can be used as a reverse proxy. Caddy provides:
|
||||||
|
|
||||||
|
- Automatic Let's Encrypt certificates (including wildcard certificates)
|
||||||
|
- HTTP to HTTPS redirect
|
||||||
|
- Optimized settings for audio streaming and file uploads
|
||||||
|
- Support for both `hoerdle.de` and `hördle.de` (Punycode: `xn--hrdle-jua.de`)
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. **Follow the setup guide**: See `CADDY_SETUP.md` for detailed instructions
|
||||||
|
2. **Configure environment variables**: Add GoDaddy API credentials to your `.env` file
|
||||||
|
3. **Start with Caddy**:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Caddy
|
||||||
|
|
||||||
|
If you don't want to use Caddy, you can deploy normally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will still be accessible on port 3010, but you'll need to configure SSL/TLS separately (e.g., with nginx).
|
||||||
|
|
||||||
|
### Caddy Troubleshooting
|
||||||
|
|
||||||
|
See `CADDY_SETUP.md` for detailed troubleshooting information.
|
||||||
|
|||||||
106
DOCKER_BUILD_FIX.md
Normal file
106
DOCKER_BUILD_FIX.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Docker Build Fix: Upload-Dateien ausschließen
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Der Docker Build schlug fehl mit:
|
||||||
|
```
|
||||||
|
no space left on device
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ursache**: Die großen MP3-Dateien in `public/uploads/` wurden in den Build-Context kopiert und verbrauchten zu viel Speicherplatz.
|
||||||
|
|
||||||
|
## Lösung
|
||||||
|
|
||||||
|
Eine `.dockerignore` Datei wurde erstellt, die folgende Dateien/Ordner vom Build ausschließt:
|
||||||
|
|
||||||
|
- `public/uploads/*` - Upload-Dateien (werden als Volume gemountet)
|
||||||
|
- `data/*` - Datenbank-Dateien (werden als Volume gemountet)
|
||||||
|
- `node_modules` - werden während des Builds installiert
|
||||||
|
- `.next`, `out`, `build` - Build-Artefakte
|
||||||
|
- Backup-Dateien, Logs, temporäre Dateien
|
||||||
|
|
||||||
|
## Zusätzliche Maßnahmen
|
||||||
|
|
||||||
|
Falls der Build weiterhin Probleme macht:
|
||||||
|
|
||||||
|
### 1. Docker aufräumen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Entferne nicht verwendete Images
|
||||||
|
docker image prune -a
|
||||||
|
|
||||||
|
# Entferne nicht verwendete Container
|
||||||
|
docker container prune
|
||||||
|
|
||||||
|
# Entferne nicht verwendete Volumes (VORSICHT: kann Daten löschen!)
|
||||||
|
docker volume prune
|
||||||
|
|
||||||
|
# Kompletter Cleanup (alles außer laufenden Containern)
|
||||||
|
docker system prune -a
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Speicherplatz prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zeige Speicherplatz
|
||||||
|
df -h
|
||||||
|
|
||||||
|
# Zeige Docker-Speicherverbrauch
|
||||||
|
docker system df
|
||||||
|
|
||||||
|
# Zeige größte Images
|
||||||
|
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" | sort -k3 -h
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build-Kontext prüfen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prüfe was in den Build-Context kopiert wird
|
||||||
|
docker build --no-cache --progress=plain -t test-build . 2>&1 | grep "transferring context"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Upload-Dateien manuell ausschließen
|
||||||
|
|
||||||
|
Falls die `.dockerignore` nicht greift, können Upload-Dateien vorübergehend verschoben werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vor dem Build
|
||||||
|
mv public/uploads public/uploads.backup
|
||||||
|
mkdir -p public/uploads
|
||||||
|
touch public/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Build durchführen
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# Uploads wiederherstellen
|
||||||
|
rm -rf public/uploads
|
||||||
|
mv public/uploads.backup public/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtig
|
||||||
|
|
||||||
|
Die Upload-Dateien werden **nicht** ins Docker-Image kopiert, sondern als Volume gemountet:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./public/uploads:/app/public/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
Das bedeutet:
|
||||||
|
- Upload-Dateien bleiben auf dem Host-System
|
||||||
|
- Sie werden zur Laufzeit gemountet
|
||||||
|
- Sie sollten **nicht** ins Image kopiert werden (spart viel Speicher)
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
Nach dem Build sollte das Image deutlich kleiner sein:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Zeige Image-Größe
|
||||||
|
docker images hoerdle-hoerdle
|
||||||
|
|
||||||
|
# Prüfe ob Uploads im Image sind
|
||||||
|
docker run --rm hoerdle-hoerdle ls -lh /app/public/uploads
|
||||||
|
# Sollte nur .gitkeep oder Covers zeigen, keine MP3-Dateien
|
||||||
|
```
|
||||||
|
|
||||||
34
Dockerfile
34
Dockerfile
@@ -23,11 +23,13 @@ RUN apk add --no-cache git
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Extract version: use build arg if provided, otherwise get from git
|
# Extract version: use build arg if provided, otherwise get from git, fallback to package.json
|
||||||
RUN if [ -n "$APP_VERSION" ]; then \
|
RUN if [ -n "$APP_VERSION" ]; then \
|
||||||
echo "$APP_VERSION" > /tmp/version.txt; \
|
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||||
else \
|
else \
|
||||||
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
|
(git describe --tags --always 2>/dev/null || \
|
||||||
|
(grep -o '"version": "[^"]*"' package.json 2>/dev/null | cut -d'"' -f4 | sed 's/^/v/') || \
|
||||||
|
echo "dev") > /tmp/version.txt; \
|
||||||
fi && \
|
fi && \
|
||||||
echo "Building version: $(cat /tmp/version.txt)"
|
echo "Building version: $(cat /tmp/version.txt)"
|
||||||
|
|
||||||
@@ -40,6 +42,34 @@ ENV NEXT_TELEMETRY_DISABLED 1
|
|||||||
ENV DATABASE_URL="file:./dev.db"
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
RUN node_modules/.bin/prisma generate
|
RUN node_modules/.bin/prisma generate
|
||||||
|
|
||||||
|
# White Label Build Arguments
|
||||||
|
ARG NEXT_PUBLIC_APP_NAME
|
||||||
|
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||||
|
ARG NEXT_PUBLIC_DOMAIN
|
||||||
|
ARG NEXT_PUBLIC_TWITTER_HANDLE
|
||||||
|
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||||
|
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||||
|
ARG NEXT_PUBLIC_THEME_COLOR
|
||||||
|
ARG NEXT_PUBLIC_BACKGROUND_COLOR
|
||||||
|
ARG NEXT_PUBLIC_CREDITS_ENABLED
|
||||||
|
ARG NEXT_PUBLIC_CREDITS_TEXT
|
||||||
|
ARG NEXT_PUBLIC_CREDITS_LINK_TEXT
|
||||||
|
ARG NEXT_PUBLIC_CREDITS_LINK_URL
|
||||||
|
|
||||||
|
# Pass env vars to build
|
||||||
|
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
|
||||||
|
ENV NEXT_PUBLIC_APP_DESCRIPTION=$NEXT_PUBLIC_APP_DESCRIPTION
|
||||||
|
ENV NEXT_PUBLIC_DOMAIN=$NEXT_PUBLIC_DOMAIN
|
||||||
|
ENV NEXT_PUBLIC_TWITTER_HANDLE=$NEXT_PUBLIC_TWITTER_HANDLE
|
||||||
|
ENV NEXT_PUBLIC_PLAUSIBLE_DOMAIN=$NEXT_PUBLIC_PLAUSIBLE_DOMAIN
|
||||||
|
ENV NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=$NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC
|
||||||
|
ENV NEXT_PUBLIC_THEME_COLOR=$NEXT_PUBLIC_THEME_COLOR
|
||||||
|
ENV NEXT_PUBLIC_BACKGROUND_COLOR=$NEXT_PUBLIC_BACKGROUND_COLOR
|
||||||
|
ENV NEXT_PUBLIC_CREDITS_ENABLED=$NEXT_PUBLIC_CREDITS_ENABLED
|
||||||
|
ENV NEXT_PUBLIC_CREDITS_TEXT=$NEXT_PUBLIC_CREDITS_TEXT
|
||||||
|
ENV NEXT_PUBLIC_CREDITS_LINK_TEXT=$NEXT_PUBLIC_CREDITS_LINK_TEXT
|
||||||
|
ENV NEXT_PUBLIC_CREDITS_LINK_URL=$NEXT_PUBLIC_CREDITS_LINK_URL
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
|
|||||||
10
Dockerfile.caddy
Normal file
10
Dockerfile.caddy
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Dockerfile für Caddy mit GoDaddy DNS-Provider Plugin
|
||||||
|
FROM caddy:2-builder AS builder
|
||||||
|
|
||||||
|
RUN xcaddy build \
|
||||||
|
--with github.com/caddy-dns/godaddy
|
||||||
|
|
||||||
|
FROM caddy:2
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
|
|
||||||
83
FIX_I18N.md
Normal file
83
FIX_I18N.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Fix für i18n-Daten (String → JSON Konvertierung)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
Die Datenbank hat Genre-/Special-/News-Namen als einfache Strings (`"Rock"`) statt JSON (`{"de": "Rock", "en": "Rock"}`) gespeichert, was zu `SyntaxError: "Rock" is not valid JSON` führt.
|
||||||
|
|
||||||
|
## Lösung: Manuell ausführen
|
||||||
|
|
||||||
|
Führe diese Befehle **direkt auf dem Server** aus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/hoerdle
|
||||||
|
|
||||||
|
# 1. Backup erstellen
|
||||||
|
docker cp hoerdle:/app/data/prod.db ./data/prod.db.backup.$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
# 2. Kopiere DB lokal
|
||||||
|
docker cp hoerdle:/app/data/prod.db ./data/prod.db.tmp
|
||||||
|
|
||||||
|
# 3. Setze Berechtigungen
|
||||||
|
sudo chmod 666 ./data/prod.db.tmp
|
||||||
|
sudo chmod 775 ./data
|
||||||
|
|
||||||
|
# 4. Prüfe ob sqlite3 installiert ist
|
||||||
|
which sqlite3 || sudo apt-get install -y sqlite3
|
||||||
|
|
||||||
|
# 5. Fixe die Datenbank (kopiere diesen Block komplett)
|
||||||
|
sqlite3 ./data/prod.db.tmp << 'EOF'
|
||||||
|
UPDATE Genre SET name = json_object('de', name, 'en', name) WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||||
|
UPDATE Genre SET subtitle = json_object('de', subtitle, 'en', subtitle) WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||||
|
UPDATE Special SET name = json_object('de', name, 'en', name) WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||||
|
UPDATE Special SET subtitle = json_object('de', subtitle, 'en', subtitle) WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||||
|
UPDATE News SET title = json_object('de', title, 'en', title) WHERE typeof(title) = 'text' AND title NOT LIKE '{%';
|
||||||
|
UPDATE News SET content = json_object('de', content, 'en', content) WHERE typeof(content) = 'text' AND content NOT LIKE '{%';
|
||||||
|
SELECT '✅ Fertig!' as status;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 6. Kopiere zurück
|
||||||
|
docker cp ./data/prod.db.tmp hoerdle:/app/data/prod.db
|
||||||
|
|
||||||
|
# 7. Aufräumen
|
||||||
|
rm ./data/prod.db.tmp
|
||||||
|
|
||||||
|
# 8. Container neu starten
|
||||||
|
docker compose restart hoerdle
|
||||||
|
|
||||||
|
# 9. Logs prüfen
|
||||||
|
docker logs hoerdle --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls Schritt 5 mit "permission denied" fehlschlägt, verwende `sudo`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo sqlite3 ./data/prod.db.tmp << 'EOF'
|
||||||
|
[... SQL-Befehle wie oben ...]
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatisiertes Skript
|
||||||
|
|
||||||
|
Alternativ kannst du das automatische Skript verwenden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/fix-i18n-easy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder das lokale Skript:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/fix-i18n-local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prüfen ob es funktioniert hat
|
||||||
|
|
||||||
|
Nach dem Neustart sollte die Seite wieder funktionieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prüfe Logs (sollte keine JSON-Fehler mehr zeigen)
|
||||||
|
docker logs hoerdle --tail=100 | grep -i "json\|error" || echo "✅ Keine JSON-Fehler gefunden"
|
||||||
|
|
||||||
|
# Teste die Seite
|
||||||
|
curl -s https://hoerdle.de/de | head -20
|
||||||
|
```
|
||||||
|
|
||||||
349
I18N.md
Normal file
349
I18N.md
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
# Internationalisierung (i18n) Dokumentation
|
||||||
|
|
||||||
|
Hördle unterstützt vollständige Mehrsprachigkeit (Internationalisierung) für Deutsch und Englisch.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Die i18n-Implementierung basiert auf [next-intl](https://next-intl-docs.vercel.app/) und nutzt den Next.js App Router mit dynamischen `[locale]`-Segmenten.
|
||||||
|
|
||||||
|
## Unterstützte Sprachen
|
||||||
|
|
||||||
|
- **Deutsch (de)** - Standardsprache
|
||||||
|
- **Englisch (en)**
|
||||||
|
|
||||||
|
## URL-Struktur
|
||||||
|
|
||||||
|
Alle Routen sind lokalisiert:
|
||||||
|
|
||||||
|
- `http://localhost:3000/` → Redirect zu `/de` (Standard)
|
||||||
|
- `http://localhost:3000/de` → Deutsche Version
|
||||||
|
- `http://localhost:3000/en` → Englische Version
|
||||||
|
- `http://localhost:3000/de/admin` → Admin-Dashboard (Deutsch)
|
||||||
|
- `http://localhost:3000/de/Rock` → Rock Genre (Deutsch)
|
||||||
|
- `http://localhost:3000/de/special/Weihnachtslieder` → Special (Deutsch)
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
### Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
[locale]/ # Lokalisierte Routen
|
||||||
|
layout.tsx # Root Layout mit i18n Provider
|
||||||
|
page.tsx # Homepage
|
||||||
|
admin/
|
||||||
|
page.tsx # Admin Dashboard
|
||||||
|
[genre]/
|
||||||
|
page.tsx # Genre-spezifische Seite
|
||||||
|
special/
|
||||||
|
[name]/
|
||||||
|
page.tsx # Special-Seite
|
||||||
|
|
||||||
|
i18n/
|
||||||
|
request.ts # next-intl Konfiguration
|
||||||
|
|
||||||
|
messages/
|
||||||
|
de.json # Deutsche Übersetzungen
|
||||||
|
en.json # Englische Übersetzungen
|
||||||
|
|
||||||
|
lib/
|
||||||
|
i18n.ts # Helper-Funktionen für lokalisierte DB-Werte
|
||||||
|
navigation.ts # Lokalisierte Navigation (Link, useRouter, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Übersetzungsdateien
|
||||||
|
|
||||||
|
Die Übersetzungen sind in JSON-Dateien unter `messages/` organisiert:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Common": {
|
||||||
|
"loading": "Laden...",
|
||||||
|
"error": "Ein Fehler ist aufgetreten"
|
||||||
|
},
|
||||||
|
"Game": {
|
||||||
|
"play": "Abspielen",
|
||||||
|
"pause": "Pause",
|
||||||
|
"won": "Gewonnen!"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"welcome": "Willkommen bei Hördle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank-Schema
|
||||||
|
|
||||||
|
Die folgenden Modelle unterstützen mehrsprachige Felder:
|
||||||
|
|
||||||
|
#### Genre
|
||||||
|
- `name`: JSON `{ "de": "Rock", "en": "Rock" }`
|
||||||
|
- `subtitle`: JSON `{ "de": "Klassischer Rock", "en": "Classic Rock" }`
|
||||||
|
|
||||||
|
#### Special
|
||||||
|
- `name`: JSON `{ "de": "Weihnachtslieder", "en": "Christmas Songs" }`
|
||||||
|
- `subtitle`: JSON `{ "de": "Festliche Musik", "en": "Festive Music" }`
|
||||||
|
|
||||||
|
#### News
|
||||||
|
- `title`: JSON `{ "de": "Neues Feature", "en": "New Feature" }`
|
||||||
|
- `content`: JSON `{ "de": "Markdown Inhalt...", "en": "Markdown content..." }`
|
||||||
|
|
||||||
|
### Helper-Funktionen
|
||||||
|
|
||||||
|
#### `getLocalizedValue(value, locale, fallback?)`
|
||||||
|
|
||||||
|
Extrahiert den lokalisierten Wert aus einem JSON-Objekt:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const genreName = getLocalizedValue(genre.name, 'de'); // "Rock"
|
||||||
|
const genreNameEn = getLocalizedValue(genre.name, 'en'); // "Rock"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fallback-Verhalten:**
|
||||||
|
1. Versucht die angeforderte Locale (`de` oder `en`)
|
||||||
|
2. Fallback zu `de` falls nicht vorhanden
|
||||||
|
3. Fallback zu `en` falls nicht vorhanden
|
||||||
|
4. Fallback zum ersten verfügbaren Schlüssel
|
||||||
|
5. Fallback zum übergebenen `fallback`-Parameter
|
||||||
|
|
||||||
|
#### `createLocalizedObject(de, en?)`
|
||||||
|
|
||||||
|
Erstellt ein lokalisiertes Objekt:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createLocalizedObject } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const name = createLocalizedObject('Rock', 'Rock');
|
||||||
|
// { de: "Rock", en: "Rock" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung in Komponenten
|
||||||
|
|
||||||
|
### Server Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export default async function Page({ params }: { params: { locale: string } }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations('Home');
|
||||||
|
|
||||||
|
const genreName = getLocalizedValue(genre.name, locale);
|
||||||
|
|
||||||
|
return <h1>{t('welcome')}</h1>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
|
export default function Game() {
|
||||||
|
const t = useTranslations('Game');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
return <button>{t('play')}</button>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
Verwende die lokalisierte Navigation aus `lib/navigation.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
|
||||||
|
// Automatisch lokalisiert
|
||||||
|
<Link href="/admin">Admin</Link>
|
||||||
|
<Link href="/Rock">Rock</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin-Interface
|
||||||
|
|
||||||
|
Das Admin-Dashboard unterstützt mehrsprachige Eingaben:
|
||||||
|
|
||||||
|
1. **Sprach-Tabs:** Wechsle zwischen `DE` und `EN` Tabs
|
||||||
|
2. **Genre/Special/News:** Alle Felder können in beiden Sprachen bearbeitet werden
|
||||||
|
3. **Vorschau:** Sieh dir die lokalisierte Version direkt an
|
||||||
|
|
||||||
|
### Beispiel: Genre erstellen
|
||||||
|
|
||||||
|
1. Öffne `/de/admin`
|
||||||
|
2. Wähle den `DE` Tab
|
||||||
|
3. Gib Name und Subtitle ein
|
||||||
|
4. Wechsle zum `EN` Tab
|
||||||
|
5. Gib die englischen Übersetzungen ein
|
||||||
|
6. Speichere
|
||||||
|
|
||||||
|
## Migration bestehender Daten
|
||||||
|
|
||||||
|
Bestehende Daten werden automatisch migriert:
|
||||||
|
|
||||||
|
1. **Migration `20251128131405_add_i18n_columns`:** Fügt neue JSON-Spalten hinzu
|
||||||
|
2. **Migration `20251128132806_switch_to_json_columns`:** Konvertiert String-Spalten zu JSON
|
||||||
|
|
||||||
|
**Wichtig:** Alte String-Werte werden automatisch in beide Sprachen kopiert:
|
||||||
|
- `"Rock"` → `{ "de": "Rock", "en": "Rock" }`
|
||||||
|
|
||||||
|
## Proxy
|
||||||
|
|
||||||
|
Der Proxy (`proxy.ts`) leitet Anfragen automatisch um:
|
||||||
|
|
||||||
|
- `/` → `/de` (Standard)
|
||||||
|
- Ungültige Locales → 404
|
||||||
|
- Validiert Locale-Parameter
|
||||||
|
|
||||||
|
## Sprachumschalter
|
||||||
|
|
||||||
|
Die `LanguageSwitcher`-Komponente ermöglicht Nutzern, zwischen Sprachen zu wechseln:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
|
|
||||||
|
<LanguageSwitcher />
|
||||||
|
```
|
||||||
|
|
||||||
|
Die aktuelle Route bleibt erhalten, nur die Locale ändert sich:
|
||||||
|
- `/de/admin` → `/en/admin`
|
||||||
|
- `/de/Rock` → `/en/Rock`
|
||||||
|
|
||||||
|
## API-Endpunkte
|
||||||
|
|
||||||
|
API-Routen unterstützen einen optionalen `locale`-Parameter:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
GET /api/genres?locale=de
|
||||||
|
GET /api/specials?locale=en
|
||||||
|
GET /api/news?locale=de
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls kein `locale` angegeben wird, wird `de` als Standard verwendet.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Immer `getLocalizedValue` verwenden
|
||||||
|
|
||||||
|
❌ **Falsch:**
|
||||||
|
```typescript
|
||||||
|
<span>{genre.name}</span> // Rendert { de: "...", en: "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Richtig:**
|
||||||
|
```typescript
|
||||||
|
<span>{getLocalizedValue(genre.name, locale)}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Übersetzungsschlüssel konsistent benennen
|
||||||
|
|
||||||
|
Verwende Namespaces für bessere Organisation:
|
||||||
|
- `Common.*` - Allgemeine UI-Elemente
|
||||||
|
- `Game.*` - Spiel-spezifische Texte
|
||||||
|
- `Home.*` - Homepage-Texte
|
||||||
|
- `Navigation.*` - Navigations-Elemente
|
||||||
|
|
||||||
|
### 3. Fallbacks definieren
|
||||||
|
|
||||||
|
Immer einen Fallback-Wert angeben:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const name = getLocalizedValue(genre.name, locale, 'Unbekannt');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Neue Übersetzungen hinzufügen
|
||||||
|
|
||||||
|
1. Füge den Schlüssel zu `messages/de.json` hinzu
|
||||||
|
2. Füge den Schlüssel zu `messages/en.json` hinzu
|
||||||
|
3. Verwende `useTranslations('Namespace')` oder `getTranslations('Namespace')`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### 404-Fehler auf `/de` oder `/en`
|
||||||
|
|
||||||
|
**Problem:** Route wird nicht gefunden.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Überprüfe, ob `proxy.ts` korrekt konfiguriert ist
|
||||||
|
2. Stelle sicher, dass `app/[locale]/layout.tsx` existiert
|
||||||
|
3. Prüfe die `i18n/request.ts` Konfiguration
|
||||||
|
|
||||||
|
### "Objects are not valid as a React child"
|
||||||
|
|
||||||
|
**Problem:** Ein JSON-Objekt wird direkt gerendert statt des lokalisierten Werts.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
Verwende `getLocalizedValue()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Falsch
|
||||||
|
<span>{genre.name}</span>
|
||||||
|
|
||||||
|
// ✅ Richtig
|
||||||
|
<span>{getLocalizedValue(genre.name, locale)}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Übersetzungen werden nicht angezeigt
|
||||||
|
|
||||||
|
**Problem:** Texte erscheinen als Schlüssel (z.B. `"Game.play"`).
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Überprüfe, ob der Übersetzungsschlüssel in `messages/de.json` und `messages/en.json` existiert
|
||||||
|
2. Stelle sicher, dass der Namespace korrekt ist: `useTranslations('Game')` für `Game.play`
|
||||||
|
3. Prüfe die JSON-Syntax auf Fehler
|
||||||
|
|
||||||
|
### Admin-Interface zeigt Objekte statt Text
|
||||||
|
|
||||||
|
**Problem:** In Dropdowns oder Listen werden `{ de: "...", en: "..." }` angezeigt.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
Verwende `getLocalizedValue()` in allen Render-Funktionen:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Falsch
|
||||||
|
<option value={s.id}>{s.name}</option>
|
||||||
|
|
||||||
|
// ✅ Richtig
|
||||||
|
<option value={s.id}>{getLocalizedValue(s.name, activeTab)}</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Erweiterung um weitere Sprachen
|
||||||
|
|
||||||
|
Um eine neue Sprache hinzuzufügen (z.B. Französisch):
|
||||||
|
|
||||||
|
1. **Übersetzungsdatei erstellen:**
|
||||||
|
```bash
|
||||||
|
cp messages/de.json messages/fr.json
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Übersetzungen hinzufügen:**
|
||||||
|
Bearbeite `messages/fr.json` mit französischen Übersetzungen
|
||||||
|
|
||||||
|
3. **Locale zur Konfiguration hinzufügen:**
|
||||||
|
- `i18n/request.ts`: `const locales = ['en', 'de', 'fr'];`
|
||||||
|
- `proxy.ts`: `locales: ['en', 'de', 'fr']`
|
||||||
|
- `lib/navigation.ts`: `export const locales = ['de', 'en', 'fr'] as const;`
|
||||||
|
|
||||||
|
4. **Layout aktualisieren:**
|
||||||
|
```typescript
|
||||||
|
// app/[locale]/layout.tsx
|
||||||
|
if (!['en', 'de', 'fr'].includes(locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **LanguageSwitcher erweitern:**
|
||||||
|
Füge einen Button für `fr` hinzu
|
||||||
|
|
||||||
|
6. **Datenbank-Migration:**
|
||||||
|
Bestehende Daten behalten ihre Struktur, neue Einträge können optional `fr` enthalten
|
||||||
|
|
||||||
|
## Weitere Ressourcen
|
||||||
|
|
||||||
|
- [next-intl Dokumentation](https://next-intl-docs.vercel.app/)
|
||||||
|
- [Next.js App Router i18n](https://nextjs.org/docs/app/building-your-application/routing/internationalization)
|
||||||
|
|
||||||
41
README.md
41
README.md
@@ -4,6 +4,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **🌍 Mehrsprachigkeit (i18n):** Vollständige Unterstützung für Deutsch und Englisch mit automatischer Sprachumleitung und lokalisierten Inhalten.
|
||||||
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
|
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
|
||||||
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
||||||
- **Admin Dashboard:**
|
- **Admin Dashboard:**
|
||||||
@@ -48,9 +49,28 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||||
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
- **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen.
|
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||||
- Verwaltung über das Admin-Dashboard.
|
- Verwaltung über das Admin-Dashboard.
|
||||||
|
|
||||||
|
## Internationalisierung (i18n)
|
||||||
|
|
||||||
|
Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
|
||||||
|
|
||||||
|
👉 **[Vollständige i18n-Dokumentation](I18N.md)**
|
||||||
|
|
||||||
|
**Schnellstart:**
|
||||||
|
- Deutsche Version: `http://localhost:3000/de`
|
||||||
|
- Englische Version: `http://localhost:3000/en`
|
||||||
|
- Root (`/`) leitet automatisch zur Standardsprache (Deutsch) um
|
||||||
|
|
||||||
|
## White Labeling
|
||||||
|
|
||||||
|
Hördle ist "White Label Ready". Das bedeutet, du kannst das Branding (Name, Farben, Logos) komplett anpassen, ohne den Code zu ändern.
|
||||||
|
|
||||||
|
👉 **[Anleitung zur Anpassung (White Label Guide)](WHITE_LABEL.md)**
|
||||||
|
|
||||||
|
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
|
||||||
|
|
||||||
## Spielregeln & Punktesystem
|
## Spielregeln & Punktesystem
|
||||||
|
|
||||||
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
|
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
|
||||||
@@ -95,12 +115,14 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
|||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
Die App läuft unter `http://localhost:3000`.
|
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/de` um).
|
||||||
|
|
||||||
## Deployment mit Docker
|
## Deployment mit Docker
|
||||||
|
|
||||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||||
|
|
||||||
|
👉 **[White Labeling mit Docker? Hier klicken!](WHITE_LABEL.md#docker-deployment)**
|
||||||
|
|
||||||
1. **Vorbereitung:**
|
1. **Vorbereitung:**
|
||||||
Kopiere die Beispiel-Konfiguration:
|
Kopiere die Beispiel-Konfiguration:
|
||||||
```bash
|
```bash
|
||||||
@@ -129,7 +151,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- Beim Start des Containers wird automatisch ein Migrations-Skript ausgeführt, das fehlende Cover-Bilder aus den MP3s extrahiert.
|
- Beim Start des Containers wird automatisch ein Migrations-Skript ausgeführt, das fehlende Cover-Bilder aus den MP3s extrahiert.
|
||||||
|
|
||||||
4. **Admin-Zugang:**
|
4. **Admin-Zugang:**
|
||||||
- URL: `/admin`
|
- URL: `/de/admin` oder `/en/admin`
|
||||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||||
|
|
||||||
5. **Special Curation & Scheduling verwenden:**
|
5. **Special Curation & Scheduling verwenden:**
|
||||||
@@ -200,12 +222,12 @@ Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die A
|
|||||||
|
|
||||||
### Genre-spezifische Einbindung
|
### Genre-spezifische Einbindung
|
||||||
|
|
||||||
Einzelne Genres können direkt eingebunden werden:
|
Einzelne Genres können direkt eingebunden werden (mit Locale-Präfix):
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Rock Genre -->
|
<!-- Rock Genre (Deutsch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/Rock"
|
src="https://hoerdle.elpatron.me/de/Rock"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@@ -213,9 +235,9 @@ Einzelne Genres können direkt eingebunden werden:
|
|||||||
title="Hördle Rock Quiz">
|
title="Hördle Rock Quiz">
|
||||||
</iframe>
|
</iframe>
|
||||||
|
|
||||||
<!-- Pop Genre -->
|
<!-- Pop Genre (Englisch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/Pop"
|
src="https://hoerdle.elpatron.me/en/Pop"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@@ -229,8 +251,9 @@ Einzelne Genres können direkt eingebunden werden:
|
|||||||
Auch thematische Specials können direkt eingebettet werden:
|
Auch thematische Specials können direkt eingebettet werden:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
|
<!-- Weihnachtslieder (Deutsch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
|
src="https://hoerdle.elpatron.me/de/special/Weihnachtslieder"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
|
|||||||
206
TROUBLESHOOTING.md
Normal file
206
TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Troubleshooting Guide
|
||||||
|
|
||||||
|
## Application Error: "a server-side exception has occurred"
|
||||||
|
|
||||||
|
Dieser Fehler tritt auf, wenn die Next.js-Anwendung auf dem Server einen Fehler hat.
|
||||||
|
|
||||||
|
### ⚠️ Datenbank-Berechtigungen (wenn DB von anderem Server kopiert wurde)
|
||||||
|
|
||||||
|
**Symptom**: Application Error nach dem Kopieren einer Datenbank von einem anderen Server
|
||||||
|
|
||||||
|
**Ursache**: SQLite benötigt Schreibrechte auf:
|
||||||
|
- Die Datenbankdatei selbst (`prod.db`)
|
||||||
|
- Das Datenbankverzeichnis (für temporäre Dateien wie `-wal`, `-shm`)
|
||||||
|
|
||||||
|
**Sofort-Lösung (auf dem Server ausführen)**:
|
||||||
|
```bash
|
||||||
|
# 1. Setze Berechtigungen für Datenbankverzeichnis und Datei
|
||||||
|
chmod 775 ./data
|
||||||
|
chmod 664 ./data/prod.db
|
||||||
|
|
||||||
|
# 2. Falls temporäre SQLite-Dateien existieren, auch diese:
|
||||||
|
chmod 664 ./data/*.db-wal 2>/dev/null || true
|
||||||
|
chmod 664 ./data/*.db-shm 2>/dev/null || true
|
||||||
|
|
||||||
|
# 3. Oder verwende das Fix-Skript:
|
||||||
|
./scripts/fix-database-permissions.sh
|
||||||
|
|
||||||
|
# 4. Container neu starten
|
||||||
|
docker compose restart hoerdle
|
||||||
|
|
||||||
|
# 5. Logs prüfen
|
||||||
|
docker logs hoerdle --tail=50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum passiert das?**
|
||||||
|
- Wenn du eine Datenbankdatei von einem anderen Server kopierst, behält sie die ursprünglichen Berechtigungen
|
||||||
|
- SQLite muss Schreibrechte haben, um zu funktionieren
|
||||||
|
- Auch das Verzeichnis braucht Schreibrechte (für SQLite-WAL-Modus)
|
||||||
|
|
||||||
|
### Sofort-Diagnose (auf dem Server ausführen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Container-Logs prüfen (die wichtigste Information!)
|
||||||
|
docker logs hoerdle --tail=100
|
||||||
|
|
||||||
|
# 2. Container-Status prüfen
|
||||||
|
docker ps | grep hoerdle
|
||||||
|
|
||||||
|
# 3. Prüfe ob Datenbank existiert
|
||||||
|
docker exec hoerdle ls -lh /app/data/prod.db
|
||||||
|
|
||||||
|
# 4. Prüfe ob Server auf Port 3000 antwortet (intern)
|
||||||
|
docker exec hoerdle curl -f http://localhost:3000/api/daily
|
||||||
|
|
||||||
|
# 5. Prüfe Health Check
|
||||||
|
docker inspect hoerdle --format='{{json .State.Health}}' | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
### Häufige Ursachen und Lösungen
|
||||||
|
|
||||||
|
#### 1. Datenbankfehler / Migrationen fehlgeschlagen
|
||||||
|
|
||||||
|
**Symptom**: Logs zeigen Prisma-Fehler oder "database locked"
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
```bash
|
||||||
|
# Container-Logs prüfen
|
||||||
|
docker logs hoerdle | grep -i "migration\|database\|prisma"
|
||||||
|
|
||||||
|
# Falls Migrationen fehlgeschlagen sind:
|
||||||
|
docker compose restart hoerdle
|
||||||
|
|
||||||
|
# Bei persistierenden Problemen: Datenbank-Backup prüfen
|
||||||
|
ls -lh ./backups/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Container läuft nicht oder ist crashed
|
||||||
|
|
||||||
|
**Symptom**: Container existiert nicht oder Status zeigt "Exited"
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
```bash
|
||||||
|
# Container-Status prüfen
|
||||||
|
docker ps -a | grep hoerdle
|
||||||
|
|
||||||
|
# Container neu starten
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Falls Container nicht startet, Logs prüfen
|
||||||
|
docker logs hoerdle --tail=200
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Caddy kann Container nicht erreichen
|
||||||
|
|
||||||
|
**Symptom**: 502 Bad Gateway oder "connection refused" in Caddy-Logs
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
```bash
|
||||||
|
# Prüfe ob hoerdle Container läuft
|
||||||
|
docker ps | grep hoerdle
|
||||||
|
|
||||||
|
# Prüfe Netzwerk
|
||||||
|
docker network inspect hoerdle_default
|
||||||
|
|
||||||
|
# Prüfe Caddy-Logs
|
||||||
|
docker logs hoerdle-caddy --tail=50
|
||||||
|
|
||||||
|
# Stelle sicher, dass Caddyfile Port 3000 verwendet (nicht 3010!)
|
||||||
|
grep "reverse_proxy" Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Fehlende Umgebungsvariablen
|
||||||
|
|
||||||
|
**Symptom**: Logs zeigen undefined variables
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
```bash
|
||||||
|
# Prüfe wichtige Umgebungsvariablen
|
||||||
|
docker exec hoerdle env | grep -E "DATABASE_URL|NODE_ENV"
|
||||||
|
|
||||||
|
# Prüfe .env Datei (falls vorhanden)
|
||||||
|
cat .env | grep DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Build-Fehler oder fehlerhafte Dateien
|
||||||
|
|
||||||
|
**Symptom**: Container startet, aber App crasht sofort
|
||||||
|
|
||||||
|
**Lösung**:
|
||||||
|
```bash
|
||||||
|
# Container komplett neu bauen
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Prüfe Build-Logs
|
||||||
|
docker compose build 2>&1 | tee build.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detaillierte Log-Analyse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Fehler in Logs finden
|
||||||
|
docker logs hoerdle 2>&1 | grep -i -E "error|exception|fatal|panic" | tail -50
|
||||||
|
|
||||||
|
# Prisma-spezifische Fehler
|
||||||
|
docker logs hoerdle 2>&1 | grep -i prisma | tail -20
|
||||||
|
|
||||||
|
# Next.js-spezifische Fehler
|
||||||
|
docker logs hoerdle 2>&1 | grep -i "next\|react" | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
### Netzwerk-Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Teste Verbindung von Caddy zu Hördle
|
||||||
|
docker exec hoerdle-caddy wget -O- http://hoerdle:3000/api/daily
|
||||||
|
|
||||||
|
# Prüfe alle Container im Netzwerk
|
||||||
|
docker network inspect hoerdle_default --format='{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank-Debugging
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prüfe Datenbank-Integrität
|
||||||
|
docker exec hoerdle npx prisma db pull
|
||||||
|
|
||||||
|
# Prüfe Datenbank-Struktur
|
||||||
|
docker exec hoerdle npx prisma studio &
|
||||||
|
# (dann Browser öffnen - erfordert X11 forwarding oder lokalen Zugriff)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick-Fix: Vollständiger Neustart
|
||||||
|
|
||||||
|
Wenn nichts anderes hilft:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup erstellen
|
||||||
|
cp ./data/prod.db ./backups/prod_$(date +%Y%m%d_%H%M%S).db
|
||||||
|
|
||||||
|
# 2. Container stoppen
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# 3. Container neu starten
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Logs beobachten
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bei weiterem Bedarf
|
||||||
|
|
||||||
|
Sammle folgende Informationen für weitere Hilfe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "=== Container Status ===" && \
|
||||||
|
docker ps -a | grep hoerdle && \
|
||||||
|
echo -e "\n=== Letzte 50 Log-Zeilen ===" && \
|
||||||
|
docker logs hoerdle --tail=50 && \
|
||||||
|
echo -e "\n=== Fehler in Logs ===" && \
|
||||||
|
docker logs hoerdle 2>&1 | grep -i error | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopiere die vollständige Ausgabe und sende sie weiter.
|
||||||
|
|
||||||
99
WHITE_LABEL.md
Normal file
99
WHITE_LABEL.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# White Labeling Guide
|
||||||
|
|
||||||
|
This application is designed to be easily white-labeled. You can customize the branding, colors, and configuration without modifying the core code.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application is configured via environment variables. You can set these in a `.env` or `.env.local` file.
|
||||||
|
|
||||||
|
### Branding
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `NEXT_PUBLIC_APP_NAME` | The name of the application. | `Hördle` |
|
||||||
|
| `NEXT_PUBLIC_APP_DESCRIPTION` | The description used in metadata. | `Daily music guessing game...` |
|
||||||
|
| `NEXT_PUBLIC_DOMAIN` | The domain name (used for sharing). | `hoerdle.elpatron.me` |
|
||||||
|
| `NEXT_PUBLIC_TWITTER_HANDLE` | Twitter handle for metadata. | `@elpatron` |
|
||||||
|
|
||||||
|
### Analytics (Plausible)
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `NEXT_PUBLIC_PLAUSIBLE_DOMAIN` | The domain to track in Plausible. | `hoerdle.elpatron.me` |
|
||||||
|
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.elpatron.me/js/script.js` |
|
||||||
|
|
||||||
|
### Credits
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `NEXT_PUBLIC_CREDITS_ENABLED` | Enable/disable footer credits (`true`/`false`). | `true` |
|
||||||
|
| `NEXT_PUBLIC_CREDITS_TEXT` | Text before the link. | `Vibe coded with ☕ and 🍺 by` |
|
||||||
|
| `NEXT_PUBLIC_CREDITS_LINK_TEXT` | Text of the link. | `@elpatron@digitalcourage.social` |
|
||||||
|
| `NEXT_PUBLIC_CREDITS_LINK_URL` | URL of the link. | `https://digitalcourage.social/@elpatron` |
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
The application uses CSS variables for theming. You can override these variables in your own CSS file or by modifying `app/globals.css`.
|
||||||
|
|
||||||
|
### Key Colors
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `--primary` | Main action color (buttons). | `#000000` |
|
||||||
|
| `--secondary` | Secondary actions. | `#4b5563` |
|
||||||
|
| `--accent` | Accent color. | `#667eea` |
|
||||||
|
| `--success` | Success state (correct guess). | `#22c55e` |
|
||||||
|
| `--danger` | Error state (wrong guess). | `#ef4444` |
|
||||||
|
| `--warning` | Warning state (stars). | `#ffc107` |
|
||||||
|
| `--muted` | Muted backgrounds. | `#f3f4f6` |
|
||||||
|
|
||||||
|
### Example: Red Theme
|
||||||
|
|
||||||
|
To create a red-themed version, add this to your CSS:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--primary: #dc2626;
|
||||||
|
--accent: #ef4444;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
To replace the logo and icons:
|
||||||
|
1. Replace `public/favicon.ico`.
|
||||||
|
2. Replace `public/icon.png` (if it exists).
|
||||||
|
3. Update `app/manifest.ts` if you have custom icon paths.
|
||||||
|
3. Update `app/manifest.ts` if you have custom icon paths.
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
When deploying with Docker, please note that **Next.js inlines `NEXT_PUBLIC_` environment variables at build time**.
|
||||||
|
|
||||||
|
This means you cannot simply change the environment variables in `docker-compose.yml` and restart the container to change the branding. You must **rebuild the image**.
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
1. Create a `.env` file with your custom configuration:
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_APP_NAME="My Music Game"
|
||||||
|
NEXT_PUBLIC_THEME_COLOR="#ff0000"
|
||||||
|
# ... other variables
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Ensure your `docker-compose.yml` passes these variables as build arguments (already configured in `docker-compose.example.yml`):
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
hoerdle:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build and start the container:
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import Game from '@/components/Game';
|
|
||||||
import NewsSection from '@/components/NewsSection';
|
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ genre: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function GenrePage({ params }: PageProps) {
|
|
||||||
const { genre } = await params;
|
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
|
||||||
|
|
||||||
// Check if genre exists and is active
|
|
||||||
const currentGenre = await prisma.genre.findUnique({
|
|
||||||
where: { name: decodedGenre }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentGenre || !currentGenre.active) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
|
||||||
const genres = await prisma.genre.findMany({
|
|
||||||
where: { active: true },
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const activeSpecials = specials.filter(s => {
|
|
||||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
|
||||||
const isEnded = s.endDate && s.endDate < now;
|
|
||||||
return isStarted && !isEnded;
|
|
||||||
});
|
|
||||||
|
|
||||||
const upcomingSpecials = specials.filter(s => {
|
|
||||||
return s.launchDate && s.launchDate > now;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{genres.map(g => (
|
|
||||||
<Link
|
|
||||||
key={g.id}
|
|
||||||
href={`/${g.name}`}
|
|
||||||
style={{
|
|
||||||
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
|
|
||||||
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
|
|
||||||
color: g.name === decodedGenre ? 'black' : '#4b5563'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
|
||||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Specials */}
|
|
||||||
{activeSpecials.map(s => (
|
|
||||||
<Link
|
|
||||||
key={s.id}
|
|
||||||
href={`/special/${s.name}`}
|
|
||||||
style={{
|
|
||||||
color: '#be185d', // Pink-700
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ {s.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upcoming Specials */}
|
|
||||||
{upcomingSpecials.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
|
||||||
Coming soon: {upcomingSpecials.map(s => (
|
|
||||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
|
||||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: process.env.TZ
|
|
||||||
}) : ''})
|
|
||||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<NewsSection />
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
134
app/[locale]/[genre]/page.tsx
Normal file
134
app/[locale]/[genre]/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import Game from '@/components/Game';
|
||||||
|
import NewsSection from '@/components/NewsSection';
|
||||||
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ locale: string; genre: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GenrePage({ params }: PageProps) {
|
||||||
|
const { locale, genre } = await params;
|
||||||
|
const decodedGenre = decodeURIComponent(genre);
|
||||||
|
const tNav = await getTranslations('Navigation');
|
||||||
|
|
||||||
|
// Fetch all genres to find the matching one by localized name
|
||||||
|
const allGenres = await prisma.genre.findMany();
|
||||||
|
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||||
|
|
||||||
|
if (!currentGenre || !currentGenre.active) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyPuzzle = await getOrCreateDailyPuzzle(currentGenre);
|
||||||
|
// getOrCreateDailyPuzzle likely expects string or needs update.
|
||||||
|
// Actually, getOrCreateDailyPuzzle takes `genreName: string | null`.
|
||||||
|
// If I pass the JSON object, it might fail.
|
||||||
|
// But wait, the DB schema for DailyPuzzle stores `genreId`.
|
||||||
|
// `getOrCreateDailyPuzzle` probably looks up genre by name.
|
||||||
|
// I should check `lib/dailyPuzzle.ts`.
|
||||||
|
// For now, I'll pass the localized name, but that might be risky if it tries to create a genre (unlikely).
|
||||||
|
// Let's assume for now I should pass the localized name if that's what it uses to find/create.
|
||||||
|
// But if `getOrCreateDailyPuzzle` uses `findUnique({ where: { name: genreName } })`, it will fail because name is JSON.
|
||||||
|
// I need to update `lib/dailyPuzzle.ts` too!
|
||||||
|
// I'll mark that as a todo. For now, let's proceed with page creation.
|
||||||
|
|
||||||
|
const genres = allGenres.filter(g => g.active);
|
||||||
|
// Sort
|
||||||
|
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||||
|
|
||||||
|
const specials = await prisma.special.findMany();
|
||||||
|
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const activeSpecials = specials.filter(s => {
|
||||||
|
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||||
|
const isEnded = s.endDate && s.endDate < now;
|
||||||
|
return isStarted && !isEnded;
|
||||||
|
});
|
||||||
|
|
||||||
|
const upcomingSpecials = specials.filter(s => {
|
||||||
|
return s.launchDate && s.launchDate > now;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>{tNav('global')}</Link>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{genres.map(g => {
|
||||||
|
const name = getLocalizedValue(g.name, locale);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={g.id}
|
||||||
|
href={`/${name}`}
|
||||||
|
style={{
|
||||||
|
fontWeight: name === decodedGenre ? 'bold' : 'normal',
|
||||||
|
textDecoration: name === decodedGenre ? 'underline' : 'none',
|
||||||
|
color: name === decodedGenre ? 'black' : '#4b5563'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Separator if both exist */}
|
||||||
|
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||||
|
<span style={{ color: '#d1d5db' }}>|</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Specials */}
|
||||||
|
{activeSpecials.map(s => {
|
||||||
|
const name = getLocalizedValue(s.name, locale);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={s.id}
|
||||||
|
href={`/special/${name}`}
|
||||||
|
style={{
|
||||||
|
color: '#be185d', // Pink-700
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★ {name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Specials */}
|
||||||
|
{upcomingSpecials.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||||
|
Coming soon: {upcomingSpecials.map(s => {
|
||||||
|
const name = getLocalizedValue(s.name, locale);
|
||||||
|
return (
|
||||||
|
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||||
|
★ {name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: process.env.TZ
|
||||||
|
}) : ''})
|
||||||
|
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<NewsSection locale={locale} />
|
||||||
|
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
app/[locale]/about/page.tsx
Normal file
256
app/[locale]/about/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Link } from "@/lib/navigation";
|
||||||
|
|
||||||
|
interface AboutPageProps {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations({ locale, namespace: "About" });
|
||||||
|
|
||||||
|
const sheetUrl =
|
||||||
|
"https://docs.google.com/spreadsheets/d/1LuMkDsnidlvMtzzSqwrz-GACnqMaqzs-VBa-ZK0nZeI/edit?usp=sharing";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
maxWidth: "960px",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "2rem 1rem",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 style={{ fontSize: "2rem", marginBottom: "1rem" }}>{t("title")}</h1>
|
||||||
|
<p style={{ marginBottom: "2rem", color: "#4b5563" }}>{t("intro")}</p>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
{t("projectTitle")}
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>{t("projectPrivateNote")}</p>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>{t("projectIdea")}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
{t("imprintTitle")}
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>
|
||||||
|
<strong>{t("imprintOperator")}</strong>
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: 0, lineHeight: "1.5" }}>
|
||||||
|
Markus Busche
|
||||||
|
<br />
|
||||||
|
Knorrstr. 16
|
||||||
|
<br />
|
||||||
|
24106 Kiel
|
||||||
|
<br />
|
||||||
|
{t("imprintCountry")}
|
||||||
|
<br />
|
||||||
|
{t("imprintEmailLabel")}{" "}
|
||||||
|
<a href="mailto:markus@hoerdle.de">markus@hoerdle.de</a>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
||||||
|
>
|
||||||
|
{t("imprintDisclaimer")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
{t("costsTitle")}
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>{t("costsIntro")}</p>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
marginLeft: "1.25rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
listStyleType: "disc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<li>{t("costsDomain")}</li>
|
||||||
|
<li>{t("costsServer")}</li>
|
||||||
|
<li>{t("costsEmail")}</li>
|
||||||
|
<li>{t("costsLicenses")}</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>
|
||||||
|
{t.rich("costsSheetLinkText", {
|
||||||
|
link: (chunks) => (
|
||||||
|
<a
|
||||||
|
href={sheetUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
color: "#6b7280",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("costsSheetPrivacyNote")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
{t("supportTitle")}
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginBottom: "1rem" }}>{t("supportIntro")}</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("supportSepaTitle")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: "0.25rem" }}>
|
||||||
|
<strong>{t("supportSepaName")}</strong>
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: 0, fontFamily: "monospace" }}>
|
||||||
|
{t("supportSepaIban")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("supportPaypalTitle")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
<a
|
||||||
|
href="https://paypal.me/MBusche"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
{t("supportPaypalLink")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "1rem",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: "600",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("supportSteadyTitle")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>
|
||||||
|
{t("supportSteadyDescription")}
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
<a
|
||||||
|
href="https://steady.page/de/hoerdle"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
https://steady.page/de/hoerdle
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
{t("privacyTitle")}
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>{t("privacyIntro")}</p>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontSize: "1.25rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("privacyPlausibleTitle")}
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>
|
||||||
|
{t("privacyPlausibleSelfHosted")}
|
||||||
|
</p>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>
|
||||||
|
{t("privacyPlausibleGemaTariff")}
|
||||||
|
</p>
|
||||||
|
<ul
|
||||||
|
style={{
|
||||||
|
marginLeft: "1.25rem",
|
||||||
|
marginBottom: "0.75rem",
|
||||||
|
listStyleType: "disc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<li>{t("privacyPlausibleNoCookies")}</li>
|
||||||
|
<li>{t("privacyPlausibleNoTrackingAcrossSites")}</li>
|
||||||
|
<li>{t("privacyPlausibleAggregated")}</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>{t("privacyServerLogs")}</p>
|
||||||
|
<p style={{ marginBottom: "0.5rem" }}>{t("privacyContact")}</p>
|
||||||
|
<p
|
||||||
|
style={{ marginTop: "0.5rem", fontSize: "0.9rem", color: "#6b7280" }}
|
||||||
|
>
|
||||||
|
{t("privacyNoLegalAdvice")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: "2rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.5rem", marginBottom: "0.5rem" }}>
|
||||||
|
{t("backTitle")}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
<Link href="/" style={{ textDecoration: "underline" }}>
|
||||||
|
{t("backToGame")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
2242
app/[locale]/admin/page.tsx
Normal file
2242
app/[locale]/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
74
app/[locale]/layout.tsx
Normal file
74
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
|
import "../globals.css"; // Adjusted path
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { getMessages } from 'next-intl/server';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
import { config } from "@/lib/config";
|
||||||
|
import InstallPrompt from "@/components/InstallPrompt";
|
||||||
|
import AppFooter from "@/components/AppFooter";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: config.appName,
|
||||||
|
description: config.appDescription,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: config.colors.themeColor,
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
|
||||||
|
console.log('[app/[locale]/layout] params locale:', locale);
|
||||||
|
|
||||||
|
// Ensure that the incoming `locale` is valid
|
||||||
|
if (!['en', 'de'].includes(locale)) {
|
||||||
|
console.log('[app/[locale]/layout] invalid locale, triggering notFound()');
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providing all messages to the client
|
||||||
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<head>
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
data-domain={config.plausibleDomain}
|
||||||
|
src={config.plausibleScriptSrc}
|
||||||
|
strategy="beforeInteractive"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
{children}
|
||||||
|
<InstallPrompt />
|
||||||
|
<AppFooter />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
app/[locale]/page.tsx
Normal file
138
app/[locale]/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import Game from '@/components/Game';
|
||||||
|
import NewsSection from '@/components/NewsSection';
|
||||||
|
import OnboardingTour from '@/components/OnboardingTour';
|
||||||
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default async function Home({
|
||||||
|
params
|
||||||
|
}: {
|
||||||
|
params: { locale: string };
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const t = await getTranslations('Home');
|
||||||
|
const tNav = await getTranslations('Navigation');
|
||||||
|
|
||||||
|
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
||||||
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
});
|
||||||
|
const specials = await prisma.special.findMany();
|
||||||
|
|
||||||
|
// Sort in memory
|
||||||
|
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||||
|
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const activeSpecials = specials.filter(s => {
|
||||||
|
const isStarted = !s.launchDate || s.launchDate <= now;
|
||||||
|
const isEnded = s.endDate && s.endDate < now;
|
||||||
|
return isStarted && !isEnded;
|
||||||
|
});
|
||||||
|
|
||||||
|
const upcomingSpecials = specials.filter(s => {
|
||||||
|
return s.launchDate && s.launchDate > now;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="tour-genres" style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6', position: 'relative' }}>
|
||||||
|
{/* Language Switcher - rechts oben */}
|
||||||
|
<div style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zentrierte Navigation */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<div className="tooltip">
|
||||||
|
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>{tNav('global')}</Link>
|
||||||
|
<span className="tooltip-text">{t('globalTooltip')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{genres.map(g => {
|
||||||
|
const name = getLocalizedValue(g.name, locale);
|
||||||
|
const subtitle = getLocalizedValue(g.subtitle, locale);
|
||||||
|
return (
|
||||||
|
<div key={g.id} className="tooltip">
|
||||||
|
<Link href={`/${name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
||||||
|
{name}
|
||||||
|
</Link>
|
||||||
|
{subtitle && <span className="tooltip-text">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Separator if both exist */}
|
||||||
|
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||||
|
<span style={{ color: '#d1d5db' }}>|</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Specials */}
|
||||||
|
{activeSpecials.map(s => {
|
||||||
|
const name = getLocalizedValue(s.name, locale);
|
||||||
|
const subtitle = getLocalizedValue(s.subtitle, locale);
|
||||||
|
return (
|
||||||
|
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
|
<div className="tooltip">
|
||||||
|
<Link
|
||||||
|
href={`/special/${name}`}
|
||||||
|
style={{
|
||||||
|
color: '#be185d', // Pink-700
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★ {name}
|
||||||
|
</Link>
|
||||||
|
{subtitle && <span className="tooltip-text">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
{s.curator && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
||||||
|
{t('curatedBy')} {s.curator}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Specials */}
|
||||||
|
{upcomingSpecials.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666', textAlign: 'center' }}>
|
||||||
|
{t('comingSoon')}: {upcomingSpecials.map(s => {
|
||||||
|
const name = getLocalizedValue(s.name, locale);
|
||||||
|
return (
|
||||||
|
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
||||||
|
★ {name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString(locale, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: process.env.TZ
|
||||||
|
}) : ''})
|
||||||
|
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>{t('curatedBy')} {s.curator}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tour-news">
|
||||||
|
<NewsSection locale={locale} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||||
|
<OnboardingTour />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
app/[locale]/special/[name]/page.tsx
Normal file
125
app/[locale]/special/[name]/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import Game from '@/components/Game';
|
||||||
|
import NewsSection from '@/components/NewsSection';
|
||||||
|
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: Promise<{ locale: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SpecialPage({ params }: PageProps) {
|
||||||
|
const { locale, name } = await params;
|
||||||
|
const decodedName = decodeURIComponent(name);
|
||||||
|
const tNav = await getTranslations('Navigation');
|
||||||
|
|
||||||
|
const allSpecials = await prisma.special.findMany();
|
||||||
|
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
|
||||||
|
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
|
||||||
|
|
||||||
|
if (!currentSpecial || !isStarted) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
|
<h1>Special Not Available</h1>
|
||||||
|
<p>This special has not launched yet or does not exist.</p>
|
||||||
|
<Link href="/">{tNav('home')}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnded) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
|
<h1>Special Ended</h1>
|
||||||
|
<p>This special event has ended.</p>
|
||||||
|
<Link href="/">{tNav('home')}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to handle getOrCreateSpecialPuzzle with localized name or ID
|
||||||
|
// Ideally pass ID or full object, but existing function takes name string.
|
||||||
|
// I'll need to update lib/dailyPuzzle.ts to handle this.
|
||||||
|
const dailyPuzzle = await getOrCreateSpecialPuzzle(currentSpecial);
|
||||||
|
|
||||||
|
const genres = await prisma.genre.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
});
|
||||||
|
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||||
|
|
||||||
|
const specials = allSpecials; // Already fetched
|
||||||
|
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
|
||||||
|
|
||||||
|
const activeSpecials = specials.filter(s => {
|
||||||
|
const sStarted = !s.launchDate || s.launchDate <= now;
|
||||||
|
const sEnded = s.endDate && s.endDate < now;
|
||||||
|
return sStarted && !sEnded;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>{tNav('global')}</Link>
|
||||||
|
|
||||||
|
{/* Genres */}
|
||||||
|
{genres.map(g => {
|
||||||
|
const gName = getLocalizedValue(g.name, locale);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={g.id}
|
||||||
|
href={`/${gName}`}
|
||||||
|
style={{
|
||||||
|
color: '#4b5563',
|
||||||
|
textDecoration: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gName}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Separator if both exist */}
|
||||||
|
{genres.length > 0 && activeSpecials.length > 0 && (
|
||||||
|
<span style={{ color: '#d1d5db' }}>|</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Specials */}
|
||||||
|
{activeSpecials.map(s => {
|
||||||
|
const sName = getLocalizedValue(s.name, locale);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={s.id}
|
||||||
|
href={`/special/${sName}`}
|
||||||
|
style={{
|
||||||
|
fontWeight: sName === decodedName ? 'bold' : 'normal',
|
||||||
|
textDecoration: sName === decodedName ? 'underline' : 'none',
|
||||||
|
color: sName === decodedName ? '#9d174d' : '#be185d'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
★ {sName}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NewsSection locale={locale} />
|
||||||
|
<Game
|
||||||
|
dailyPuzzle={dailyPuzzle}
|
||||||
|
genre={decodedName}
|
||||||
|
isSpecial={true}
|
||||||
|
maxAttempts={dailyPuzzle?.maxAttempts}
|
||||||
|
unlockSteps={dailyPuzzle?.unlockSteps}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,14 +98,14 @@ export async function DELETE(request: Request) {
|
|||||||
where: { id: puzzle.specialId }
|
where: { id: puzzle.specialId }
|
||||||
});
|
});
|
||||||
if (special) {
|
if (special) {
|
||||||
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
|
newPuzzle = await getOrCreateSpecialPuzzle(special);
|
||||||
}
|
}
|
||||||
} else if (puzzle.genreId) {
|
} else if (puzzle.genreId) {
|
||||||
const genre = await prisma.genre.findUnique({
|
const genre = await prisma.genre.findUnique({
|
||||||
where: { id: puzzle.genreId }
|
where: { id: puzzle.genreId }
|
||||||
});
|
});
|
||||||
if (genre) {
|
if (genre) {
|
||||||
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
|
newPuzzle = await getOrCreateDailyPuzzle(genre);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newPuzzle = await getOrCreateDailyPuzzle(null);
|
newPuzzle = await getOrCreateDailyPuzzle(null);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { readFile, stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -30,24 +31,106 @@ export async function GET(
|
|||||||
return new NextResponse('Forbidden', { status: 403 });
|
return new NextResponse('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
const stats = await stat(filePath);
|
||||||
try {
|
const fileSize = stats.size;
|
||||||
await stat(filePath);
|
const range = request.headers.get('range');
|
||||||
} catch {
|
|
||||||
return new NextResponse('File not found', { status: 404 });
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
const chunksize = (end - start) + 1;
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath, { start, end });
|
||||||
|
|
||||||
|
// Convert Node stream to Web stream
|
||||||
|
|
||||||
|
const readable = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
stream.on('data', (chunk: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
} catch (e) {
|
||||||
|
isClosed = true;
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (err: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(readable, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': chunksize.toString(),
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
|
||||||
|
// Convert Node stream to Web stream
|
||||||
|
const readable = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
stream.on('data', (chunk: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
} catch (e) {
|
||||||
|
isClosed = true;
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (err: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(readable, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read file
|
|
||||||
const fileBuffer = await readFile(filePath);
|
|
||||||
|
|
||||||
// Return with proper headers
|
|
||||||
return new NextResponse(fileBuffer, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'audio/mpeg',
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error serving audio file:', error);
|
console.error('Error serving audio file:', error);
|
||||||
return new NextResponse('Internal Server Error', { status: 500 });
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -83,7 +84,8 @@ export async function POST(request: Request) {
|
|||||||
// Process each song in this batch
|
// Process each song in this batch
|
||||||
for (const song of uncategorizedSongs) {
|
for (const song of uncategorizedSongs) {
|
||||||
try {
|
try {
|
||||||
const genreNames = allGenres.map(g => g.name);
|
// Use German names for AI categorization (primary language)
|
||||||
|
const genreNames = allGenres.map(g => getLocalizedValue(g.name, 'de'));
|
||||||
|
|
||||||
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
|
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ Your response:`;
|
|||||||
|
|
||||||
// Filter to only valid genres and get their IDs
|
// Filter to only valid genres and get their IDs
|
||||||
const genreIds = allGenres
|
const genreIds = allGenres
|
||||||
.filter(g => suggestedGenreNames.includes(g.name))
|
.filter(g => suggestedGenreNames.includes(getLocalizedValue(g.name, 'de')))
|
||||||
.map(g => g.id)
|
.map(g => g.id)
|
||||||
.slice(0, 3); // Max 3 genres
|
.slice(0, 3); // Max 3 genres
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ Your response:`;
|
|||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
assignedGenres: suggestedGenreNames.filter(name =>
|
assignedGenres: suggestedGenreNames.filter(name =>
|
||||||
allGenres.some(g => g.name === name)
|
allGenres.some(g => getLocalizedValue(g.name, 'de') === name)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const genreName = searchParams.get('genre');
|
const genreName = searchParams.get('genre');
|
||||||
|
|
||||||
const puzzle = await getOrCreateDailyPuzzle(genreName);
|
let genre = null;
|
||||||
|
if (genreName) {
|
||||||
|
// Find genre by localized name (try both locales)
|
||||||
|
const allGenres = await prisma.genre.findMany();
|
||||||
|
genre = allGenres.find(g =>
|
||||||
|
getLocalizedValue(g.name, 'de') === genreName ||
|
||||||
|
getLocalizedValue(g.name, 'en') === genreName
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const puzzle = await getOrCreateDailyPuzzle(genre);
|
||||||
|
|
||||||
if (!puzzle) {
|
if (!puzzle) {
|
||||||
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const genres = await prisma.genre.findMany({
|
const genres = await prisma.genre.findMany({
|
||||||
orderBy: { name: 'asc' },
|
// orderBy: { name: 'asc' }, // Cannot sort by JSON field easily in SQLite
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { songs: true }
|
select: { songs: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sort in memory if needed, or just return
|
||||||
|
// If locale is provided, map to localized values
|
||||||
|
if (locale) {
|
||||||
|
const localizedGenres = genres.map(g => ({
|
||||||
|
...g,
|
||||||
|
name: getLocalizedValue(g.name, locale),
|
||||||
|
subtitle: getLocalizedValue(g.subtitle, locale)
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return NextResponse.json(localizedGenres);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(genres);
|
return NextResponse.json(genres);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching genres:', error);
|
console.error('Error fetching genres:', error);
|
||||||
@@ -29,14 +45,18 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { name, subtitle, active } = await request.json();
|
const { name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure name is stored as JSON
|
||||||
|
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
|
||||||
|
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||||
|
|
||||||
const genre = await prisma.genre.create({
|
const genre = await prisma.genre.create({
|
||||||
data: {
|
data: {
|
||||||
name: name.trim(),
|
name: nameData,
|
||||||
subtitle: subtitle ? subtitle.trim() : null,
|
subtitle: subtitleData,
|
||||||
active: active !== undefined ? active : true
|
active: active !== undefined ? active : true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -83,13 +103,14 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
|
||||||
|
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||||
|
if (active !== undefined) updateData.active = active;
|
||||||
|
|
||||||
const genre = await prisma.genre.update({
|
const genre = await prisma.genre.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: updateData,
|
||||||
...(name && { name: name.trim() }),
|
|
||||||
subtitle: subtitle ? subtitle.trim() : null,
|
|
||||||
...(active !== undefined && { active })
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(genre);
|
return NextResponse.json(genre);
|
||||||
|
|||||||
5
app/api/health/route.ts
Normal file
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok' }, { status: 200 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = parseInt(searchParams.get('limit') || '10');
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
const featuredOnly = searchParams.get('featured') === 'true';
|
const featuredOnly = searchParams.get('featured') === 'true';
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const where = featuredOnly ? { featured: true } : {};
|
const where = featuredOnly ? { featured: true } : {};
|
||||||
|
|
||||||
@@ -27,6 +29,19 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (locale) {
|
||||||
|
const localizedNews = news.map(item => ({
|
||||||
|
...item,
|
||||||
|
title: getLocalizedValue(item.title, locale),
|
||||||
|
content: getLocalizedValue(item.content, locale),
|
||||||
|
special: item.special ? {
|
||||||
|
...item.special,
|
||||||
|
name: getLocalizedValue(item.special.name, locale)
|
||||||
|
} : null
|
||||||
|
}));
|
||||||
|
return NextResponse.json(localizedNews);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(news);
|
return NextResponse.json(news);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching news:', error);
|
console.error('Error fetching news:', error);
|
||||||
@@ -52,10 +67,14 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure title and content are stored as JSON
|
||||||
|
const titleData = typeof title === 'string' ? { de: title, en: title } : title;
|
||||||
|
const contentData = typeof content === 'string' ? { de: content, en: content } : content;
|
||||||
|
|
||||||
const news = await prisma.news.create({
|
const news = await prisma.news.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title: titleData,
|
||||||
content,
|
content: contentData,
|
||||||
author: author || null,
|
author: author || null,
|
||||||
featured: featured || false,
|
featured: featured || false,
|
||||||
specialId: specialId || null
|
specialId: specialId || null
|
||||||
@@ -93,8 +112,8 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
if (title !== undefined) updateData.title = title;
|
if (title !== undefined) updateData.title = typeof title === 'string' ? { de: title, en: title } : title;
|
||||||
if (content !== undefined) updateData.content = content;
|
if (content !== undefined) updateData.content = typeof content === 'string' ? { de: content, en: content } : content;
|
||||||
if (author !== undefined) updateData.author = author || null;
|
if (author !== undefined) updateData.author = author || null;
|
||||||
if (featured !== undefined) updateData.featured = featured;
|
if (featured !== undefined) updateData.featured = featured;
|
||||||
if (specialId !== undefined) updateData.specialId = specialId || null;
|
if (specialId !== undefined) updateData.specialId = specialId || null;
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import { PrismaClient, Special } from '@prisma/client';
|
import { PrismaClient, Special } from '@prisma/client';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const specials = await prisma.special.findMany({
|
const specials = await prisma.special.findMany({
|
||||||
orderBy: { name: 'asc' },
|
// orderBy: { name: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { songs: true }
|
select: { songs: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (locale) {
|
||||||
|
const localizedSpecials = specials.map(s => ({
|
||||||
|
...s,
|
||||||
|
name: getLocalizedValue(s.name, locale),
|
||||||
|
subtitle: getLocalizedValue(s.subtitle, locale)
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return NextResponse.json(localizedSpecials);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(specials);
|
return NextResponse.json(specials);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +39,15 @@ export async function POST(request: Request) {
|
|||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure name is stored as JSON
|
||||||
|
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
|
||||||
|
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||||
|
|
||||||
const special = await prisma.special.create({
|
const special = await prisma.special.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name: nameData,
|
||||||
subtitle: subtitle || null,
|
subtitle: subtitleData,
|
||||||
maxAttempts: Number(maxAttempts),
|
maxAttempts: Number(maxAttempts),
|
||||||
unlockSteps,
|
unlockSteps,
|
||||||
launchDate: launchDate ? new Date(launchDate) : null,
|
launchDate: launchDate ? new Date(launchDate) : null,
|
||||||
@@ -61,17 +80,19 @@ export async function PUT(request: Request) {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
|
||||||
|
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||||
|
if (maxAttempts) updateData.maxAttempts = Number(maxAttempts);
|
||||||
|
if (unlockSteps) updateData.unlockSteps = unlockSteps;
|
||||||
|
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
|
||||||
|
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
|
||||||
|
if (curator !== undefined) updateData.curator = curator || null;
|
||||||
|
|
||||||
const updated = await prisma.special.update({
|
const updated = await prisma.special.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: updateData,
|
||||||
...(name && { name }),
|
|
||||||
subtitle: subtitle || null, // Allow clearing or setting
|
|
||||||
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
|
|
||||||
...(unlockSteps && { unlockSteps }),
|
|
||||||
launchDate: launchDate ? new Date(launchDate) : null,
|
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
|
||||||
curator: curator || null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET() {
|
|||||||
for (const versionFilePath of versionPaths) {
|
for (const versionFilePath of versionPaths) {
|
||||||
if (existsSync(versionFilePath)) {
|
if (existsSync(versionFilePath)) {
|
||||||
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
||||||
if (version && version !== 'unknown') {
|
if (version && version !== 'unknown' && version !== '') {
|
||||||
return NextResponse.json({ version });
|
return NextResponse.json({ version });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,19 @@ export async function GET() {
|
|||||||
return NextResponse.json({ version: process.env.APP_VERSION });
|
return NextResponse.json({ version: process.env.APP_VERSION });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: check package.json
|
||||||
|
try {
|
||||||
|
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||||
|
if (existsSync(packageJsonPath)) {
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
if (packageJson.version) {
|
||||||
|
return NextResponse.json({ version: `v${packageJson.version}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore package.json read errors
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: try to get from git (local development)
|
// Fallback: try to get from git (local development)
|
||||||
let version = 'dev';
|
let version = 'dev';
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
--foreground-rgb: 0, 0, 0;
|
--foreground-rgb: 0, 0, 0;
|
||||||
--background-start-rgb: 214, 219, 220;
|
--background-start-rgb: 214, 219, 220;
|
||||||
--background-end-rgb: 255, 255, 255;
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
|
||||||
|
/* Theme Colors */
|
||||||
|
--primary: #000000;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #4b5563;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--accent: #667eea;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--success: #22c55e;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--warning: #ffc107;
|
||||||
|
--muted: #f3f4f6;
|
||||||
|
--muted-foreground: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--input: #d1d5db;
|
||||||
|
--ring: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -51,13 +69,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Audio Player */
|
/* Audio Player */
|
||||||
.audio-player {
|
.audio-player {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
@@ -73,8 +91,8 @@ body {
|
|||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #000;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -85,19 +103,20 @@ body {
|
|||||||
|
|
||||||
.play-button:hover {
|
.play-button:hover {
|
||||||
background: #333;
|
background: #333;
|
||||||
|
/* Keep for now or add --primary-hover */
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-container {
|
.progress-bar-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
background: #d1d5db;
|
background: var(--input);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
transition: width 0.1s linear;
|
transition: width 0.1s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +133,7 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
@@ -125,7 +144,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guess-text {
|
.guess-text {
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
/* Red for wrong */
|
/* Red for wrong */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +154,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guess-text.correct {
|
.guess-text.correct {
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input */
|
/* Input */
|
||||||
@@ -148,14 +167,14 @@ body {
|
|||||||
.guess-input {
|
.guess-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guess-input:focus {
|
.guess-input:focus {
|
||||||
outline: 2px solid #000;
|
outline: 2px solid var(--ring);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +182,7 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
max-height: 15rem;
|
max-height: 15rem;
|
||||||
@@ -177,11 +196,11 @@ body {
|
|||||||
.suggestion-item {
|
.suggestion-item {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-item:hover {
|
.suggestion-item:hover {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-title {
|
.suggestion-title {
|
||||||
@@ -190,14 +209,14 @@ body {
|
|||||||
|
|
||||||
.suggestion-artist {
|
.suggestion-artist {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip-button {
|
.skip-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: var(--accent-gradient);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -246,7 +265,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-card {
|
.admin-card {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -265,14 +284,14 @@ body {
|
|||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #000;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -292,8 +311,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #4b5563;
|
background: var(--secondary);
|
||||||
color: #fff;
|
color: var(--secondary-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -312,8 +331,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: #ef4444;
|
background: var(--danger);
|
||||||
color: #fff;
|
color: var(--danger-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -337,8 +356,8 @@ body {
|
|||||||
padding: 2rem 1rem 1rem;
|
padding: 2rem 1rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid var(--border);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +366,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-footer a {
|
.app-footer a {
|
||||||
color: #000;
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -375,7 +394,7 @@ body {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-grid {
|
.statistics-grid {
|
||||||
@@ -391,7 +410,7 @@ body {
|
|||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgba(255, 255, 255, 0.8);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-badge {
|
.stat-badge {
|
||||||
@@ -401,7 +420,7 @@ body {
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -409,7 +428,7 @@ body {
|
|||||||
.stat-count {
|
.stat-count {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000;
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip */
|
/* Tooltip */
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import Script from "next/script";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Hördle",
|
|
||||||
description: "Daily music guessing game - Guess the song from short audio clips",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
themeColor: "#000000",
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1,
|
|
||||||
maximumScale: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
|
||||||
import AppFooter from "@/components/AppFooter";
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<Script
|
|
||||||
defer
|
|
||||||
data-domain="hoerdle.elpatron.me"
|
|
||||||
src="https://plausible.elpatron.me/js/script.js"
|
|
||||||
strategy="beforeInteractive"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
|
||||||
{children}
|
|
||||||
<InstallPrompt />
|
|
||||||
<AppFooter />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { MetadataRoute } from 'next'
|
import type { MetadataRoute } from 'next'
|
||||||
|
import { config } from '@/lib/config'
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: 'Hördle',
|
name: config.appName,
|
||||||
short_name: 'Hördle',
|
short_name: config.appName,
|
||||||
description: 'Daily music guessing game - Guess the song from short audio clips',
|
description: config.appDescription,
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#ffffff',
|
background_color: config.colors.backgroundColor,
|
||||||
theme_color: '#000000',
|
theme_color: config.colors.themeColor,
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: '/favicon.ico',
|
src: '/favicon.ico',
|
||||||
|
|||||||
103
app/page.tsx
103
app/page.tsx
@@ -1,103 +0,0 @@
|
|||||||
import Game from '@/components/Game';
|
|
||||||
import NewsSection from '@/components/NewsSection';
|
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
|
||||||
const genres = await prisma.genre.findMany({
|
|
||||||
where: { active: true },
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const activeSpecials = specials.filter(s => {
|
|
||||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
|
||||||
const isEnded = s.endDate && s.endDate < now;
|
|
||||||
return isStarted && !isEnded;
|
|
||||||
});
|
|
||||||
|
|
||||||
const upcomingSpecials = specials.filter(s => {
|
|
||||||
return s.launchDate && s.launchDate > now;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<div className="tooltip">
|
|
||||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
|
||||||
<span className="tooltip-text">A random song from the entire collection</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{genres.map(g => (
|
|
||||||
<div key={g.id} className="tooltip">
|
|
||||||
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
|
||||||
{g.name}
|
|
||||||
</Link>
|
|
||||||
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
|
||||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active Specials */}
|
|
||||||
{activeSpecials.map(s => (
|
|
||||||
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
<div className="tooltip">
|
|
||||||
<Link
|
|
||||||
href={`/special/${s.name}`}
|
|
||||||
style={{
|
|
||||||
color: '#be185d', // Pink-700
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ {s.name}
|
|
||||||
</Link>
|
|
||||||
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
|
|
||||||
</div>
|
|
||||||
{s.curator && (
|
|
||||||
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
|
||||||
Curated by {s.curator}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upcoming Specials */}
|
|
||||||
{upcomingSpecials.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
|
||||||
Coming soon: {upcomingSpecials.map(s => (
|
|
||||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
|
||||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: process.env.TZ
|
|
||||||
}) : ''})
|
|
||||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewsSection />
|
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import Game from '@/components/Game';
|
|
||||||
import NewsSection from '@/components/NewsSection';
|
|
||||||
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ name: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SpecialPage({ params }: PageProps) {
|
|
||||||
const { name } = await params;
|
|
||||||
const decodedName = decodeURIComponent(name);
|
|
||||||
|
|
||||||
const currentSpecial = await prisma.special.findUnique({
|
|
||||||
where: { name: decodedName }
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
|
|
||||||
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
|
|
||||||
|
|
||||||
if (!currentSpecial || !isStarted) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
||||||
<h1>Special Not Available</h1>
|
|
||||||
<p>This special has not launched yet or does not exist.</p>
|
|
||||||
<Link href="/">Go Home</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEnded) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
||||||
<h1>Special Ended</h1>
|
|
||||||
<p>This special event has ended.</p>
|
|
||||||
<Link href="/">Go Home</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
|
||||||
const genres = await prisma.genre.findMany({
|
|
||||||
where: { active: true },
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
|
||||||
|
|
||||||
const activeSpecials = specials.filter(s => {
|
|
||||||
const sStarted = !s.launchDate || s.launchDate <= now;
|
|
||||||
const sEnded = s.endDate && s.endDate < now;
|
|
||||||
return sStarted && !sEnded;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{genres.map(g => (
|
|
||||||
<Link
|
|
||||||
key={g.id}
|
|
||||||
href={`/${g.name}`}
|
|
||||||
style={{
|
|
||||||
color: '#4b5563',
|
|
||||||
textDecoration: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
|
||||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Specials */}
|
|
||||||
{activeSpecials.map(s => (
|
|
||||||
<Link
|
|
||||||
key={s.id}
|
|
||||||
href={`/special/${s.name}`}
|
|
||||||
style={{
|
|
||||||
fontWeight: s.name === decodedName ? 'bold' : 'normal',
|
|
||||||
textDecoration: s.name === decodedName ? 'underline' : 'none',
|
|
||||||
color: s.name === decodedName ? '#9d174d' : '#be185d'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ {s.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<NewsSection />
|
|
||||||
<Game
|
|
||||||
dailyPuzzle={dailyPuzzle}
|
|
||||||
genre={decodedName}
|
|
||||||
isSpecial={true}
|
|
||||||
maxAttempts={dailyPuzzle?.maxAttempts}
|
|
||||||
unlockSteps={dailyPuzzle?.unlockSteps}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,47 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { config } from "@/lib/config";
|
||||||
|
import { Link } from "@/lib/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function AppFooter() {
|
export default function AppFooter() {
|
||||||
const [version, setVersion] = useState<string>('');
|
const [version, setVersion] = useState<string>("");
|
||||||
|
const t = useTranslations("About");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/version')
|
fetch("/api/version")
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(data => setVersion(data.version))
|
.then((data) => setVersion(data.version))
|
||||||
.catch(() => setVersion(''));
|
.catch(() => setVersion(""));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
if (!config.credits.enabled) return null;
|
||||||
<footer className="app-footer">
|
|
||||||
<p>
|
return (
|
||||||
Vibe coded with ☕ and 🍺 by{' '}
|
<footer className="app-footer">
|
||||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
<p>
|
||||||
@elpatron@digitalcourage.social
|
{config.credits.text}{" "}
|
||||||
</a>
|
<a
|
||||||
{' '}- for personal use among friends only!
|
href={config.credits.linkUrl}
|
||||||
{version && (
|
target="_blank"
|
||||||
<>
|
rel="noopener noreferrer"
|
||||||
{' '}·{' '}
|
>
|
||||||
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
|
{config.credits.linkText}
|
||||||
{version}
|
</a>
|
||||||
</span>
|
{version && (
|
||||||
</>
|
<>
|
||||||
)}
|
{" "}
|
||||||
</p>
|
·{" "}
|
||||||
</footer>
|
<span style={{ fontSize: "0.85em", opacity: 0.7 }}>{version}</span>
|
||||||
);
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>
|
||||||
|
<Link href="/about" style={{ textDecoration: "underline" }}>
|
||||||
|
{t("footerLinkLabel")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,33 +22,75 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
||||||
|
|
||||||
|
const [processedSrc, setProcessedSrc] = useState(src);
|
||||||
|
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState(unlockedSeconds);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[AudioPlayer] MOUNTED');
|
||||||
|
return () => console.log('[AudioPlayer] UNMOUNTED');
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
// Check if props changed compared to what we last processed
|
||||||
audioRef.current.currentTime = startTime;
|
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
|
||||||
setIsPlaying(false);
|
|
||||||
setProgress(0);
|
|
||||||
setHasPlayedOnce(false); // Reset for new segment
|
|
||||||
onHasPlayedChange?.(false); // Notify parent
|
|
||||||
|
|
||||||
if (autoPlay) {
|
if (hasChanged) {
|
||||||
const playPromise = audioRef.current.play();
|
audioRef.current.pause();
|
||||||
if (playPromise !== undefined) {
|
|
||||||
playPromise
|
let startPos = startTime;
|
||||||
.then(() => {
|
|
||||||
setIsPlaying(true);
|
// If same song but more time unlocked, start from where previous segment ended
|
||||||
onPlay?.();
|
if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
|
||||||
setHasPlayedOnce(true);
|
startPos = startTime + processedUnlockedSeconds;
|
||||||
onHasPlayedChange?.(true); // Notify parent
|
}
|
||||||
})
|
|
||||||
.catch(error => {
|
const targetPos = startPos;
|
||||||
console.log("Autoplay prevented:", error);
|
audioRef.current.currentTime = targetPos;
|
||||||
setIsPlaying(false);
|
|
||||||
});
|
// Ensure position is set correctly even if browser resets it
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
|
||||||
|
audioRef.current.currentTime = targetPos;
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
setIsPlaying(false);
|
||||||
|
|
||||||
|
// Calculate initial progress
|
||||||
|
const initialElapsed = startPos - startTime;
|
||||||
|
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
|
||||||
|
setProgress(Math.min(initialPercent, 100));
|
||||||
|
|
||||||
|
setHasPlayedOnce(false); // Reset for new segment
|
||||||
|
onHasPlayedChange?.(false); // Notify parent
|
||||||
|
|
||||||
|
// Update processed state
|
||||||
|
setProcessedSrc(src);
|
||||||
|
setProcessedUnlockedSeconds(unlockedSeconds);
|
||||||
|
|
||||||
|
if (autoPlay) {
|
||||||
|
// Delay play slightly to ensure currentTime sticks
|
||||||
|
setTimeout(() => {
|
||||||
|
const playPromise = audioRef.current?.play();
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
playPromise
|
||||||
|
.then(() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
onPlay?.();
|
||||||
|
setHasPlayedOnce(true);
|
||||||
|
onHasPlayedChange?.(true); // Notify parent
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log("Autoplay prevented:", error);
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [src, unlockedSeconds, startTime, autoPlay]);
|
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
|
||||||
|
|
||||||
// Expose play method to parent component
|
// Expose play method to parent component
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
@@ -148,4 +190,6 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
|
|
||||||
AudioPlayer.displayName = 'AudioPlayer';
|
AudioPlayer.displayName = 'AudioPlayer';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default AudioPlayer;
|
export default AudioPlayer;
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { config } from '@/lib/config';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
import { useGameState } from '../lib/gameState';
|
import { useGameState } from '../lib/gameState';
|
||||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
|
||||||
|
// Plausible Analytics
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface GameProps {
|
interface GameProps {
|
||||||
dailyPuzzle: {
|
dailyPuzzle: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,10 +37,12 @@ interface GameProps {
|
|||||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||||
|
|
||||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
|
const locale = useLocale();
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||||||
const [hasWon, setHasWon] = useState(false);
|
const [hasWon, setHasWon] = useState(false);
|
||||||
const [hasLost, setHasLost] = useState(false);
|
const [hasLost, setHasLost] = useState(false);
|
||||||
const [shareText, setShareText] = useState('🔗 Share');
|
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||||
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
||||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||||
@@ -76,7 +87,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dailyPuzzle) {
|
if (dailyPuzzle) {
|
||||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||||
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
setHasRated(true);
|
setHasRated(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -87,13 +98,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
if (!dailyPuzzle) return (
|
if (!dailyPuzzle) return (
|
||||||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
<h2>No Puzzle Available</h2>
|
<h2>{t('noPuzzleAvailable')}</h2>
|
||||||
<p>Could not generate a daily puzzle.</p>
|
<p>{t('noPuzzleDescription')}</p>
|
||||||
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
|
||||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!gameState) return <div>Loading state...</div>;
|
if (!gameState) return <div>{t('loadingState')}</div>;
|
||||||
|
|
||||||
const handleGuess = (song: any) => {
|
const handleGuess = (song: any) => {
|
||||||
if (isProcessingGuess) return;
|
if (isProcessingGuess) return;
|
||||||
@@ -103,6 +114,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (song.id === dailyPuzzle.songId) {
|
if (song.id === dailyPuzzle.songId) {
|
||||||
addGuess(song.title, true);
|
addGuess(song.title, true);
|
||||||
setHasWon(true);
|
setHasWon(true);
|
||||||
|
// Track puzzle solved event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length + 1,
|
||||||
|
score: gameState.score + 20, // Include the win bonus
|
||||||
|
outcome: 'won'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// Notification sent after year guess or skip
|
// Notification sent after year guess or skip
|
||||||
if (!dailyPuzzle.releaseYear) {
|
if (!dailyPuzzle.releaseYear) {
|
||||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
@@ -112,6 +134,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: maxAttempts,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +171,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: maxAttempts,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -148,6 +192,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length + 1,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +211,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
addYearBonus(correct);
|
addYearBonus(correct);
|
||||||
setShowYearModal(false);
|
setShowYearModal(false);
|
||||||
|
|
||||||
|
// Update the puzzle_solved event with year bonus result
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length,
|
||||||
|
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
|
||||||
|
outcome: 'won',
|
||||||
|
year_bonus: correct ? 'correct' : 'incorrect'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification now that game is fully complete
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||||
};
|
};
|
||||||
@@ -163,6 +231,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const handleYearSkip = () => {
|
const handleYearSkip = () => {
|
||||||
skipYearBonus();
|
skipYearBonus();
|
||||||
setShowYearModal(false);
|
setShowYearModal(false);
|
||||||
|
|
||||||
|
// Update the puzzle_solved event with year bonus result
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length,
|
||||||
|
score: gameState.score, // Score already includes win bonus
|
||||||
|
outcome: 'won',
|
||||||
|
year_bonus: 'skipped'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification now that game is fully complete
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
};
|
};
|
||||||
@@ -175,23 +257,28 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
for (let i = 0; i < totalGuesses; i++) {
|
for (let i = 0; i < totalGuesses; i++) {
|
||||||
if (i < gameState.guesses.length) {
|
if (i < gameState.guesses.length) {
|
||||||
if (hasWon && i === gameState.guesses.length - 1) {
|
if (gameState.guesses[i] === 'SKIPPED') {
|
||||||
emojiGrid += '🟩';
|
|
||||||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
|
||||||
emojiGrid += '⬛';
|
emojiGrid += '⬛';
|
||||||
|
} else if (hasWon && i === gameState.guesses.length - 1) {
|
||||||
|
emojiGrid += '🟩';
|
||||||
} else {
|
} else {
|
||||||
emojiGrid += '🟥';
|
emojiGrid += '🟥';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
emojiGrid += '⬜';
|
// If game is lost, fill remaining slots with black squares
|
||||||
|
emojiGrid += hasLost ? '⬛' : '⬜';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||||
|
|
||||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
let shareUrl = `https://${config.domain}`;
|
||||||
|
// Add locale prefix if not default (de)
|
||||||
|
if (locale !== 'de') {
|
||||||
|
shareUrl += `/${locale}`;
|
||||||
|
}
|
||||||
if (genre) {
|
if (genre) {
|
||||||
if (isSpecial) {
|
if (isSpecial) {
|
||||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||||
@@ -200,7 +287,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\n${t('score')}: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
|
||||||
|
|
||||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
|
|
||||||
@@ -210,8 +297,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
||||||
text: text,
|
text: text,
|
||||||
});
|
});
|
||||||
setShareText('✓ Shared!');
|
setShareText(t('shared'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name !== 'AbortError') {
|
if ((err as Error).name !== 'AbortError') {
|
||||||
@@ -222,12 +309,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setShareText('✓ Copied!');
|
setShareText(t('copied'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clipboard failed:', err);
|
console.error('Clipboard failed:', err);
|
||||||
setShareText('✗ Failed');
|
setShareText(t('shareFailed'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -238,10 +325,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||||
setHasRated(true);
|
setHasRated(true);
|
||||||
|
|
||||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
ratedPuzzles.push(dailyPuzzle.id);
|
ratedPuzzles.push(dailyPuzzle.id);
|
||||||
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
|
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to submit rating', error);
|
console.error('Failed to submit rating', error);
|
||||||
@@ -251,30 +338,34 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||||
Next puzzle in: {timeUntilNext}
|
{t('nextPuzzle')}: {timeUntilNext}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="game-board">
|
<main className="game-board">
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
<div className="status-bar">
|
<div id="tour-status" className="status-bar">
|
||||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
<span>{t('attempt')} {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||||
<span>{unlockedSeconds}s unlocked</span>
|
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
<div id="tour-score">
|
||||||
|
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<AudioPlayer
|
<div id="tour-player">
|
||||||
ref={audioPlayerRef}
|
<AudioPlayer
|
||||||
src={dailyPuzzle.audioUrl}
|
ref={audioPlayerRef}
|
||||||
unlockedSeconds={unlockedSeconds}
|
src={dailyPuzzle.audioUrl}
|
||||||
startTime={dailyPuzzle.startTime}
|
unlockedSeconds={unlockedSeconds}
|
||||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
startTime={dailyPuzzle.startTime}
|
||||||
onReplay={addReplay}
|
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||||
onHasPlayedChange={setHasPlayedAudio}
|
onReplay={addReplay}
|
||||||
/>
|
onHasPlayedChange={setHasPlayedAudio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="guess-list">
|
<div className="guess-list">
|
||||||
@@ -284,7 +375,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
<div key={i} className="guess-item">
|
<div key={i} className="guess-item">
|
||||||
<span className="guess-number">#{i + 1}</span>
|
<span className="guess-number">#{i + 1}</span>
|
||||||
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
||||||
{isCorrect ? 'Correct!' : guess}
|
{isCorrect ? t('correct') : guess}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -293,15 +384,18 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
{!hasWon && !hasLost && (
|
{!hasWon && !hasLost && (
|
||||||
<>
|
<>
|
||||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
<div id="tour-input">
|
||||||
|
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||||
|
</div>
|
||||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||||
<button
|
<button
|
||||||
|
id="tour-controls"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="skip-button"
|
className="skip-button"
|
||||||
>
|
>
|
||||||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||||
? 'Start'
|
? t('start')
|
||||||
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
|
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -313,7 +407,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Solve (Give Up)
|
{t('solveGiveUp')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -322,15 +416,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
{(hasWon || hasLost) && (
|
{(hasWon || hasLost) && (
|
||||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||||
{hasWon ? 'You won!' : 'Game Over'}
|
{hasWon ? t('won') : t('lost')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
|
||||||
Score: {gameState.score}
|
{t('score')}: {gameState.score}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
|
||||||
<summary>Score Breakdown</summary>
|
<summary>{t('scoreBreakdown')}</summary>
|
||||||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
{gameState.scoreBreakdown.map((item, i) => (
|
{gameState.scoreBreakdown.map((item, i) => (
|
||||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||||
@@ -343,22 +437,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
|
<p>{hasWon ? t('comeBackTomorrow') : t('theSongWas')}</p>
|
||||||
|
|
||||||
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
<img
|
<img
|
||||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||||
alt="Album Cover"
|
alt={t('albumCover')}
|
||||||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||||||
/>
|
/>
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||||
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>{t('released')}: {dailyPuzzle.releaseYear}</p>
|
||||||
)}
|
)}
|
||||||
<audio controls style={{ width: '100%' }}>
|
<audio controls style={{ width: '100%' }}>
|
||||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||||
Your browser does not support the audio element.
|
{t('yourBrowserDoesNotSupport')}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -405,20 +499,22 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
margin: '0.5rem 0',
|
margin: '0.5rem 0',
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
background: '#f3f4f6',
|
background: 'var(--muted)',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
cursor: 'help'
|
cursor: 'help'
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: '#666' }}>{expression} = </span>
|
<span style={{ color: 'var(--muted-foreground)' }}>{expression} = </span>
|
||||||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [options, setOptions] = useState<number[]>([]);
|
const [options, setOptions] = useState<number[]>([]);
|
||||||
|
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -447,6 +543,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||||
}, [correctYear]);
|
}, [correctYear]);
|
||||||
|
|
||||||
|
const handleGuess = (year: number) => {
|
||||||
|
const correct = year === correctYear;
|
||||||
|
setFeedback({ show: true, correct, guessedYear: year });
|
||||||
|
|
||||||
|
// Close modal after showing feedback
|
||||||
|
setTimeout(() => {
|
||||||
|
onGuess(year);
|
||||||
|
}, 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
setFeedback({ show: true, correct: false });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onSkip();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -470,67 +584,98 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||||
}}>
|
}}>
|
||||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
{!feedback.show ? (
|
||||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
<>
|
||||||
|
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>{t('bonusRound')}</h3>
|
||||||
|
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>{t('guessReleaseYear')} <strong style={{ color: 'var(--success)' }}>+10 {t('points')}</strong>!</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||||
|
gap: '0.75rem',
|
||||||
|
marginBottom: '1.5rem'
|
||||||
|
}}>
|
||||||
|
{options.map(year => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
onClick={() => handleGuess(year)}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '2px solid var(--border)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'var(--secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
|
||||||
|
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
|
||||||
gap: '0.75rem',
|
|
||||||
marginBottom: '1.5rem'
|
|
||||||
}}>
|
|
||||||
{options.map(year => (
|
|
||||||
<button
|
<button
|
||||||
key={year}
|
onClick={handleSkip}
|
||||||
onClick={() => onGuess(year)}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem',
|
background: 'none',
|
||||||
background: '#f3f4f6',
|
border: 'none',
|
||||||
border: '2px solid #e5e7eb',
|
color: 'var(--muted-foreground)',
|
||||||
borderRadius: '0.5rem',
|
textDecoration: 'underline',
|
||||||
fontSize: '1.1rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#374151',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
fontSize: '0.9rem'
|
||||||
}}
|
}}
|
||||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
|
||||||
>
|
>
|
||||||
{year}
|
{t('skipBonus')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<div style={{ padding: '2rem 0' }}>
|
||||||
<button
|
{feedback.guessedYear ? (
|
||||||
onClick={onSkip}
|
feedback.correct ? (
|
||||||
style={{
|
<>
|
||||||
background: 'none',
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||||
border: 'none',
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>{t('correct')}</h3>
|
||||||
color: '#6b7280',
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||||
textDecoration: 'underline',
|
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 {t('points')}!</p>
|
||||||
cursor: 'pointer',
|
</>
|
||||||
fontSize: '0.9rem'
|
) : (
|
||||||
}}
|
<>
|
||||||
>
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||||||
Skip Bonus
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>{t('notQuite')}</h3>
|
||||||
</button>
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('youGuessed')} {feedback.guessedYear}</p>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>{t('actuallyReleasedIn')} <strong>{correctYear}</strong></p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>⏭️</div>
|
||||||
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>{t('skipped')}</h3>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [hover, setHover] = useState(0);
|
const [hover, setHover] = useState(0);
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
|
|
||||||
if (hasRated) {
|
if (hasRated) {
|
||||||
return <div style={{ color: '#666', fontStyle: 'italic' }}>Thanks for rating!</div>;
|
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>{t('thanksForRating')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span style={{ fontSize: '0.875rem', color: '#666', fontWeight: '500' }}>Rate this puzzle:</span>
|
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
|
||||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||||
{[...Array(5)].map((_, index) => {
|
{[...Array(5)].map((_, index) => {
|
||||||
const ratingValue = index + 1;
|
const ratingValue = index + 1;
|
||||||
@@ -543,7 +688,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '2rem',
|
fontSize: '2rem',
|
||||||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
|
||||||
transition: 'color 0.2s',
|
transition: 'color 0.2s',
|
||||||
padding: '0 0.25rem'
|
padding: '0 0.25rem'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,6 +15,7 @@ interface GuessInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [songs, setSongs] = useState<Song[]>([]);
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
||||||
@@ -53,7 +55,7 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
|
placeholder={disabled ? t('gameOverPlaceholder') : t('knowItSearch')}
|
||||||
className="guess-input"
|
className="guess-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function InstallPrompt() {
|
export default function InstallPrompt() {
|
||||||
|
const t = useTranslations('InstallPrompt');
|
||||||
const [isIOS, setIsIOS] = useState(false);
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
const [isStandalone, setIsStandalone] = useState(false);
|
const [isStandalone, setIsStandalone] = useState(false);
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
@@ -80,9 +82,9 @@ export default function InstallPrompt() {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>Install Hördle App</h3>
|
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>{t('installApp')}</h3>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||||
Install the app for a better experience and quick access!
|
{t('installDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -102,7 +104,7 @@ export default function InstallPrompt() {
|
|||||||
|
|
||||||
{isIOS ? (
|
{isIOS ? (
|
||||||
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
Tap <span style={{ fontSize: '1.2rem' }}>share</span> then "Add to Home Screen" <span style={{ fontSize: '1.2rem' }}>+</span>
|
{t('iosInstructions')} <span style={{ fontSize: '1.2rem' }}>{t('iosShare')}</span> {t('iosThen')} <span style={{ fontSize: '1.2rem' }}>+</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -118,7 +120,7 @@ export default function InstallPrompt() {
|
|||||||
marginTop: '0.5rem'
|
marginTop: '0.5rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Install App
|
{t('installButton')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
|
|||||||
59
components/LanguageSwitcher.tsx
Normal file
59
components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from '@/lib/navigation';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
|
export default function LanguageSwitcher() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const switchLocale = (newLocale: 'de' | 'en') => {
|
||||||
|
router.replace(pathname, { locale: newLocale });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.25rem',
|
||||||
|
gap: '0.25rem'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => switchLocale('de')}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: locale === 'de' ? 'white' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: locale === 'de' ? '600' : '400',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: locale === 'de' ? '#111827' : '#6b7280',
|
||||||
|
boxShadow: locale === 'de' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchLocale('en')}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: locale === 'en' ? 'white' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: locale === 'en' ? '600' : '400',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: locale === 'en' ? '#111827' : '#6b7280',
|
||||||
|
boxShadow: locale === 'en' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,33 +2,38 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
interface NewsItem {
|
interface NewsItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: any;
|
||||||
content: string;
|
content: any;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
special: {
|
special: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: any;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewsSection() {
|
interface NewsSectionProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsSection({ locale }: NewsSectionProps) {
|
||||||
const [news, setNews] = useState<NewsItem[]>([]);
|
const [news, setNews] = useState<NewsItem[]>([]);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNews();
|
fetchNews();
|
||||||
}, []);
|
}, [locale]);
|
||||||
|
|
||||||
const fetchNews = async () => {
|
const fetchNews = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/news?limit=3');
|
const res = await fetch(`/api/news?limit=3&locale=${locale}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setNews(data);
|
setNews(data);
|
||||||
@@ -115,7 +120,7 @@ export default function NewsSection() {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#111827'
|
color: '#111827'
|
||||||
}}>
|
}}>
|
||||||
{item.title}
|
{getLocalizedValue(item.title, locale)}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,14 +150,14 @@ export default function NewsSection() {
|
|||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/special/${item.special.name}`}
|
href={`/special/${getLocalizedValue(item.special.name, locale)}`}
|
||||||
style={{
|
style={{
|
||||||
color: '#be185d',
|
color: '#be185d',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
★ {item.special.name}
|
★ {getLocalizedValue(item.special.name, locale)}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -187,7 +192,7 @@ export default function NewsSection() {
|
|||||||
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.content}
|
{getLocalizedValue(item.content, locale)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
112
components/OnboardingTour.tsx
Normal file
112
components/OnboardingTour.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import 'driver.js/dist/driver.css';
|
||||||
|
|
||||||
|
export default function OnboardingTour() {
|
||||||
|
const t = useTranslations('OnboardingTour');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
|
||||||
|
|
||||||
|
if (hasCompletedOnboarding) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
showProgress: true,
|
||||||
|
animate: true,
|
||||||
|
allowClose: true,
|
||||||
|
doneBtnText: t('done'),
|
||||||
|
nextBtnText: t('next'),
|
||||||
|
prevBtnText: t('previous'),
|
||||||
|
onDestroyed: () => {
|
||||||
|
localStorage.setItem('hoerdle_onboarding_completed', 'true');
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
element: '#tour-genres',
|
||||||
|
popover: {
|
||||||
|
title: t('genresSpecials'),
|
||||||
|
description: t('genresSpecialsDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-news',
|
||||||
|
popover: {
|
||||||
|
title: t('news'),
|
||||||
|
description: t('newsDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-title',
|
||||||
|
popover: {
|
||||||
|
title: t('hoerdle'),
|
||||||
|
description: t('hoerdleDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-status',
|
||||||
|
popover: {
|
||||||
|
title: t('attempts'),
|
||||||
|
description: t('attemptsDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-score',
|
||||||
|
popover: {
|
||||||
|
title: t('score'),
|
||||||
|
description: t('scoreDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-player',
|
||||||
|
popover: {
|
||||||
|
title: t('player'),
|
||||||
|
description: t('playerDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-input',
|
||||||
|
popover: {
|
||||||
|
title: t('input'),
|
||||||
|
description: t('inputDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-controls',
|
||||||
|
popover: {
|
||||||
|
title: t('controls'),
|
||||||
|
description: t('controlsDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
driverObj.drive();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Statistics as StatsType } from '../lib/gameState';
|
import { Statistics as StatsType } from '../lib/gameState';
|
||||||
|
|
||||||
interface StatisticsProps {
|
interface StatisticsProps {
|
||||||
@@ -18,6 +19,7 @@ const BADGES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Statistics({ statistics }: StatisticsProps) {
|
export default function Statistics({ statistics }: StatisticsProps) {
|
||||||
|
const t = useTranslations('Statistics');
|
||||||
const total =
|
const total =
|
||||||
statistics.solvedIn1 +
|
statistics.solvedIn1 +
|
||||||
statistics.solvedIn2 +
|
statistics.solvedIn2 +
|
||||||
@@ -36,19 +38,19 @@ export default function Statistics({ statistics }: StatisticsProps) {
|
|||||||
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
||||||
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
||||||
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
|
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
|
||||||
{ attempts: 'Failed', count: statistics.failed, badge: BADGES.failed },
|
{ attempts: t('failed'), count: statistics.failed, badge: BADGES.failed },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="statistics-container">
|
<div className="statistics-container">
|
||||||
<h3 className="statistics-title">Your Statistics</h3>
|
<h3 className="statistics-title">{t('yourStatistics')}</h3>
|
||||||
<p className="statistics-total">Total puzzles: {total}</p>
|
<p className="statistics-total">{t('totalPuzzles')}: {total}</p>
|
||||||
<div className="statistics-grid">
|
<div className="statistics-grid">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, index) => (
|
||||||
<div key={index} className="stat-item">
|
<div key={index} className="stat-item">
|
||||||
<div className="stat-badge">{stat.badge}</div>
|
<div className="stat-badge">{stat.badge}</div>
|
||||||
<div className="stat-label">
|
<div className="stat-label">
|
||||||
{typeof stat.attempts === 'number' ? `${stat.attempts} try` : stat.attempts}
|
{typeof stat.attempts === 'number' ? `${stat.attempts} ${t('try')}` : stat.attempts}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-count">{stat.count}</div>
|
<div className="stat-count">{stat.count}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
59
docker-compose.caddy.yml
Normal file
59
docker-compose.caddy.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Docker Compose Konfiguration für Caddy Reverse Proxy
|
||||||
|
# Optional: Nur in Produktionsumgebung verwenden
|
||||||
|
#
|
||||||
|
# Starten: docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
|
||||||
|
# Stoppen: docker compose -f docker-compose.yml -f docker-compose.caddy.yml down
|
||||||
|
|
||||||
|
services:
|
||||||
|
caddy:
|
||||||
|
# Verwende Custom-Image mit GoDaddy DNS-Plugin
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.caddy
|
||||||
|
# Alternativ: Verwende Standard-Caddy und manuelle DNS-Konfiguration
|
||||||
|
# image: caddy:2-alpine
|
||||||
|
container_name: hoerdle-caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# Standard HTTP/HTTPS Ports
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp" # Für HTTP/3 (QUIC)
|
||||||
|
environment:
|
||||||
|
# GoDaddy API-Credentials für DNS-01 Challenge
|
||||||
|
# Diese müssen in einer .env-Datei gesetzt werden:
|
||||||
|
# GODADDY_API_KEY=your_api_key
|
||||||
|
# GODADDY_API_SECRET=your_api_secret
|
||||||
|
- GODADDY_API_KEY=${GODADDY_API_KEY:-}
|
||||||
|
- GODADDY_API_SECRET=${GODADDY_API_SECRET:-}
|
||||||
|
# Optional: Email für Let's Encrypt Benachrichtigungen
|
||||||
|
- CADDY_EMAIL=${CADDY_EMAIL:-}
|
||||||
|
volumes:
|
||||||
|
# Caddyfile-Konfiguration
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
# Persistente Zertifikat-Speicherung
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
# Health Check
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "caddy", "version"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
# Nur starten, wenn ENABLE_CADDY=true gesetzt ist
|
||||||
|
profiles:
|
||||||
|
- production
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
driver: local
|
||||||
|
caddy_config:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: hoerdle_default
|
||||||
|
external: true
|
||||||
|
|
||||||
@@ -4,6 +4,19 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||||
|
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||||
|
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
||||||
|
NEXT_PUBLIC_TWITTER_HANDLE: ${NEXT_PUBLIC_TWITTER_HANDLE}
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_DOMAIN: ${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||||
|
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||||
|
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
||||||
|
NEXT_PUBLIC_CREDITS_ENABLED: ${NEXT_PUBLIC_CREDITS_ENABLED}
|
||||||
|
NEXT_PUBLIC_CREDITS_TEXT: ${NEXT_PUBLIC_CREDITS_TEXT}
|
||||||
|
NEXT_PUBLIC_CREDITS_LINK_TEXT: ${NEXT_PUBLIC_CREDITS_LINK_TEXT}
|
||||||
|
NEXT_PUBLIC_CREDITS_LINK_URL: ${NEXT_PUBLIC_CREDITS_LINK_URL}
|
||||||
user: root
|
user: root
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -24,6 +37,11 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
# Run migrations and start server (auto-baseline on first run if needed)
|
networks:
|
||||||
command: >
|
- default
|
||||||
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"
|
# docker-entrypoint.sh handles migrations and server startup (with baseline fallback)
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: hoerdle_default
|
||||||
|
external: true
|
||||||
|
|||||||
20
i18n/request.ts
Normal file
20
i18n/request.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
|
||||||
|
const locales = ['en', 'de'] as const;
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
// `requestLocale` kommt von next-intl (z.B. aus dem [locale]-Segment oder Fallback)
|
||||||
|
let locale = await requestLocale;
|
||||||
|
|
||||||
|
console.log('[i18n/request] incoming requestLocale:', locale);
|
||||||
|
|
||||||
|
if (!locale || !locales.includes(locale as (typeof locales)[number])) {
|
||||||
|
locale = 'de';
|
||||||
|
console.log('[i18n/request] falling back to default locale:', locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default
|
||||||
|
};
|
||||||
|
});
|
||||||
18
lib/config.ts
Normal file
18
lib/config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const config = {
|
||||||
|
appName: process.env.NEXT_PUBLIC_APP_NAME || 'Hördle',
|
||||||
|
appDescription: process.env.NEXT_PUBLIC_APP_DESCRIPTION || 'Daily music guessing game - Guess the song from short audio clips',
|
||||||
|
domain: process.env.NEXT_PUBLIC_DOMAIN || 'hoerdle.elpatron.me',
|
||||||
|
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || '@elpatron',
|
||||||
|
plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'hoerdle.elpatron.me',
|
||||||
|
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.elpatron.me/js/script.js',
|
||||||
|
colors: {
|
||||||
|
themeColor: process.env.NEXT_PUBLIC_THEME_COLOR || '#000000',
|
||||||
|
backgroundColor: process.env.NEXT_PUBLIC_BACKGROUND_COLOR || '#ffffff',
|
||||||
|
},
|
||||||
|
credits: {
|
||||||
|
enabled: process.env.NEXT_PUBLIC_CREDITS_ENABLED !== 'false',
|
||||||
|
text: process.env.NEXT_PUBLIC_CREDITS_TEXT || 'Vibe coded with ☕ and 🍺 by',
|
||||||
|
linkText: process.env.NEXT_PUBLIC_CREDITS_LINK_TEXT || '@elpatron@digitalcourage.social',
|
||||||
|
linkUrl: process.env.NEXT_PUBLIC_CREDITS_LINK_URL || 'https://digitalcourage.social/@elpatron',
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,22 +1,15 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, Genre, Special } from '@prisma/client';
|
||||||
import { getTodayISOString } from './dateUtils';
|
import { getTodayISOString } from './dateUtils';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||||
try {
|
try {
|
||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
let genreId: number | null = null;
|
let genreId: number | null = null;
|
||||||
|
|
||||||
if (genreName) {
|
if (genre) {
|
||||||
const genre = await prisma.genre.findUnique({
|
genreId = genre.id;
|
||||||
where: { name: genreName }
|
|
||||||
});
|
|
||||||
if (genre) {
|
|
||||||
genreId = genre.id;
|
|
||||||
} else {
|
|
||||||
return null; // Genre not found
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||||
@@ -27,8 +20,6 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
include: { song: true },
|
include: { song: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!dailyPuzzle) {
|
if (!dailyPuzzle) {
|
||||||
// Get songs available for this genre
|
// Get songs available for this genre
|
||||||
const whereClause = genreId
|
const whereClause = genreId
|
||||||
@@ -45,7 +36,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (allSongs.length === 0) {
|
if (allSongs.length === 0) {
|
||||||
console.log(`[Daily Puzzle] No songs available for genre: ${genreName || 'Global'}`);
|
console.log(`[Daily Puzzle] No songs available for genre: ${genre ? JSON.stringify(genre.name) : 'Global'}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +71,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
},
|
},
|
||||||
include: { song: true },
|
include: { song: true },
|
||||||
});
|
});
|
||||||
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genreName || 'Global'}) with song: ${selectedSong.title}`);
|
console.log(`[Daily Puzzle] Created new puzzle for ${today} (Genre: ${genre ? JSON.stringify(genre.name) : 'Global'}) with song: ${selectedSong.title}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Handle race condition
|
// Handle race condition
|
||||||
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
|
console.log('[Daily Puzzle] Creation failed, trying to fetch again (likely race condition)');
|
||||||
@@ -119,7 +110,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
artist: dailyPuzzle.song.artist,
|
artist: dailyPuzzle.song.artist,
|
||||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||||
releaseYear: dailyPuzzle.song.releaseYear,
|
releaseYear: dailyPuzzle.song.releaseYear,
|
||||||
genre: genreName
|
genre: genre ? genre.name : null
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -128,16 +119,10 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateSpecialPuzzle(specialName: string) {
|
export async function getOrCreateSpecialPuzzle(special: Special) {
|
||||||
try {
|
try {
|
||||||
const today = getTodayISOString();
|
const today = getTodayISOString();
|
||||||
|
|
||||||
const special = await prisma.special.findUnique({
|
|
||||||
where: { name: specialName }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!special) return null;
|
|
||||||
|
|
||||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||||
where: {
|
where: {
|
||||||
date: today,
|
date: today,
|
||||||
@@ -232,7 +217,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
|||||||
artist: dailyPuzzle.song.artist,
|
artist: dailyPuzzle.song.artist,
|
||||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||||
releaseYear: dailyPuzzle.song.releaseYear,
|
releaseYear: dailyPuzzle.song.releaseYear,
|
||||||
special: specialName,
|
special: special.name,
|
||||||
maxAttempts: special.maxAttempts,
|
maxAttempts: special.maxAttempts,
|
||||||
unlockSteps: JSON.parse(special.unlockSteps),
|
unlockSteps: JSON.parse(special.unlockSteps),
|
||||||
startTime: specialSong?.startTime || 0
|
startTime: specialSong?.startTime || 0
|
||||||
|
|||||||
41
lib/i18n.ts
Normal file
41
lib/i18n.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type LocalizedString = {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLocalizedValue(
|
||||||
|
value: any,
|
||||||
|
locale: string,
|
||||||
|
fallback: string = ''
|
||||||
|
): string {
|
||||||
|
if (!value) return fallback;
|
||||||
|
|
||||||
|
// If it's already a string, return it (backward compatibility or simple values)
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
|
||||||
|
// If it's an object, try to get the requested locale
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (value[locale]) return value[locale];
|
||||||
|
|
||||||
|
// Fallback to 'de'
|
||||||
|
if (value['de']) return value['de'];
|
||||||
|
|
||||||
|
// Fallback to 'en'
|
||||||
|
if (value['en']) return value['en'];
|
||||||
|
|
||||||
|
// Fallback to first key
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (keys.length > 0) return value[keys[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLocalizedObject(
|
||||||
|
de: string,
|
||||||
|
en?: string
|
||||||
|
): LocalizedString {
|
||||||
|
return {
|
||||||
|
de: de.trim(),
|
||||||
|
en: (en || de).trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
9
lib/navigation.ts
Normal file
9
lib/navigation.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
|
||||||
|
export const locales = ['de', 'en'] as const;
|
||||||
|
export const localePrefix = 'always'; // Default
|
||||||
|
|
||||||
|
export const { Link, redirect, usePathname, useRouter } = createNavigation({
|
||||||
|
locales,
|
||||||
|
localePrefix
|
||||||
|
});
|
||||||
198
messages/de.json
Normal file
198
messages/de.json
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"Common": {
|
||||||
|
"loading": "Laden...",
|
||||||
|
"error": "Ein Fehler ist aufgetreten",
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"back": "Zurück"
|
||||||
|
},
|
||||||
|
"Navigation": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"admin": "Admin",
|
||||||
|
"global": "Global",
|
||||||
|
"news": "Neuigkeiten"
|
||||||
|
},
|
||||||
|
"Game": {
|
||||||
|
"play": "Abspielen",
|
||||||
|
"pause": "Pause",
|
||||||
|
"skip": "Überspringen",
|
||||||
|
"submit": "Raten",
|
||||||
|
"next": "Nächstes",
|
||||||
|
"won": "Gewonnen!",
|
||||||
|
"lost": "Verloren",
|
||||||
|
"correct": "Richtig!",
|
||||||
|
"wrong": "Falsch",
|
||||||
|
"guessPlaceholder": "Lied oder Interpret eingeben...",
|
||||||
|
"attempts": "Versuche",
|
||||||
|
"share": "Teilen",
|
||||||
|
"nextPuzzle": "Nächstes Rätsel in",
|
||||||
|
"noPuzzleAvailable": "Kein Rätsel verfügbar",
|
||||||
|
"noPuzzleDescription": "Tägliches Rätsel konnte nicht generiert werden.",
|
||||||
|
"noPuzzleGenre": "Bitte stelle sicher, dass Songs in der Datenbank vorhanden sind",
|
||||||
|
"goToAdmin": "Zum Admin-Dashboard gehen",
|
||||||
|
"loadingState": "Lade Status...",
|
||||||
|
"attempt": "Versuch",
|
||||||
|
"unlocked": "freigeschaltet",
|
||||||
|
"start": "Start",
|
||||||
|
"skipWithBonus": "Überspringen (+{seconds}s)",
|
||||||
|
"solveGiveUp": "Lösen (Aufgeben)",
|
||||||
|
"comeBackTomorrow": "Komm morgen zurück für ein neues Lied.",
|
||||||
|
"theSongWas": "Das Lied war:",
|
||||||
|
"score": "Punkte",
|
||||||
|
"scoreBreakdown": "Punkteaufschlüsselung",
|
||||||
|
"albumCover": "Album-Cover",
|
||||||
|
"released": "Veröffentlicht",
|
||||||
|
"yourBrowserDoesNotSupport": "Ihr Browser unterstützt das Audio-Element nicht.",
|
||||||
|
"thanksForRating": "Danke für die Bewertung!",
|
||||||
|
"rateThisPuzzle": "Bewerte dieses Rätsel:",
|
||||||
|
"shared": "✓ Geteilt!",
|
||||||
|
"copied": "✓ Kopiert!",
|
||||||
|
"shareFailed": "✗ Fehlgeschlagen",
|
||||||
|
"bonusRound": "Bonus-Runde!",
|
||||||
|
"guessReleaseYear": "Errate das Veröffentlichungsjahr für",
|
||||||
|
"points": "Punkte",
|
||||||
|
"skipBonus": "Bonus überspringen",
|
||||||
|
"notQuite": "Nicht ganz!",
|
||||||
|
"youGuessed": "Du hast geraten",
|
||||||
|
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
|
||||||
|
"skipped": "Übersprungen",
|
||||||
|
"gameOverPlaceholder": "Spiel beendet",
|
||||||
|
"knowItSearch": "Weißt du es? Suche nach Interpret / Titel",
|
||||||
|
"special": "Special",
|
||||||
|
"genre": "Genre"
|
||||||
|
},
|
||||||
|
"Statistics": {
|
||||||
|
"yourStatistics": "Deine Statistiken",
|
||||||
|
"totalPuzzles": "Gesamte Rätsel",
|
||||||
|
"try": "Versuch",
|
||||||
|
"failed": "Verloren"
|
||||||
|
},
|
||||||
|
"OnboardingTour": {
|
||||||
|
"done": "Fertig",
|
||||||
|
"next": "Weiter",
|
||||||
|
"previous": "Zurück",
|
||||||
|
"genresSpecials": "Genres & Specials",
|
||||||
|
"genresSpecialsDescription": "Wähle hier ein bestimmtes Genre oder ein kuratiertes Special-Event.",
|
||||||
|
"news": "Neuigkeiten",
|
||||||
|
"newsDescription": "Bleibe auf dem Laufenden mit den neuesten Nachrichten und Ankündigungen.",
|
||||||
|
"hoerdle": "Hördle",
|
||||||
|
"hoerdleDescription": "Das ist das tägliche Rätsel. Ein neues Lied jeden Tag pro Genre.",
|
||||||
|
"attempts": "Versuche",
|
||||||
|
"attemptsDescription": "Du hast eine begrenzte Anzahl von Versuchen, um das Lied zu erraten.",
|
||||||
|
"score": "Punkte",
|
||||||
|
"scoreDescription": "Deine aktuelle Punktzahl. Versuche sie hoch zu halten!",
|
||||||
|
"player": "Player",
|
||||||
|
"playerDescription": "Höre dir den Ausschnitt an. Jedes zusätzliche Abspielen reduziert deine mögliche Punktzahl.",
|
||||||
|
"input": "Eingabe",
|
||||||
|
"inputDescription": "Gib hier deine Vermutung ein. Suche nach Interpret oder Titel.",
|
||||||
|
"controls": "Steuerung",
|
||||||
|
"controlsDescription": "Starte die Musik oder überspringe zum nächsten Ausschnitt, wenn du feststeckst."
|
||||||
|
},
|
||||||
|
"InstallPrompt": {
|
||||||
|
"installApp": "Hördle App installieren",
|
||||||
|
"installDescription": "Installiere die App für eine bessere Erfahrung und schnellen Zugriff!",
|
||||||
|
"iosInstructions": "Tippe auf",
|
||||||
|
"iosShare": "Teilen",
|
||||||
|
"iosThen": "dann \"Zum Home-Bildschirm hinzufügen\"",
|
||||||
|
"installButton": "App installieren"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"welcome": "Willkommen bei Hördle",
|
||||||
|
"subtitle": "Errate den Song anhand kurzer Ausschnitte",
|
||||||
|
"globalTooltip": "Ein zufälliger Song aus der gesamten Sammlung",
|
||||||
|
"comingSoon": "Demnächst",
|
||||||
|
"curatedBy": "Kuratiert von"
|
||||||
|
},
|
||||||
|
"Admin": {
|
||||||
|
"title": "Hördle Admin Dashboard",
|
||||||
|
"login": "Admin Login",
|
||||||
|
"password": "Passwort",
|
||||||
|
"loginButton": "Login",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"manageSpecials": "Specials verwalten",
|
||||||
|
"manageGenres": "Genres verwalten",
|
||||||
|
"manageNews": "News & Ankündigungen verwalten",
|
||||||
|
"uploadSongs": "Songs hochladen",
|
||||||
|
"todaysPuzzles": "Heutige tägliche Rätsel",
|
||||||
|
"show": "▶ Anzeigen",
|
||||||
|
"hide": "▼ Ausblenden",
|
||||||
|
"addSpecial": "Special hinzufügen",
|
||||||
|
"addGenre": "Genre hinzufügen",
|
||||||
|
"addNews": "News hinzufügen",
|
||||||
|
"edit": "Bearbeiten",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"curate": "Kurieren",
|
||||||
|
"name": "Name",
|
||||||
|
"subtitle": "Untertitel",
|
||||||
|
"maxAttempts": "Max. Versuche",
|
||||||
|
"unlockSteps": "Freischalt-Schritte",
|
||||||
|
"launchDate": "Startdatum",
|
||||||
|
"endDate": "Enddatum",
|
||||||
|
"curator": "Kurator",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"newGenreName": "Neuer Genre-Name",
|
||||||
|
"editSpecial": "Special bearbeiten",
|
||||||
|
"editGenre": "Genre bearbeiten",
|
||||||
|
"editNews": "News bearbeiten",
|
||||||
|
"newsTitle": "News-Titel",
|
||||||
|
"content": "Inhalt (Markdown unterstützt)",
|
||||||
|
"author": "Autor (optional)",
|
||||||
|
"featured": "Hervorgehoben",
|
||||||
|
"noSpecialLink": "Kein Special-Link",
|
||||||
|
"noNewsItems": "Noch keine News-Einträge. Erstelle einen oben!",
|
||||||
|
"noPuzzlesToday": "Keine täglichen Rätsel für heute gefunden.",
|
||||||
|
"category": "Kategorie",
|
||||||
|
"song": "Song",
|
||||||
|
"artist": "Interpret",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"deletePuzzle": "Löschen",
|
||||||
|
"wrongPassword": "Falsches Passwort"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "Über Hördle & Impressum",
|
||||||
|
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",
|
||||||
|
"projectTitle": "Über dieses Projekt",
|
||||||
|
"projectPrivateNote": "Hördle wird privat in der Freizeit entwickelt, betrieben, kuratiert und finanziert. Es besteht kein Anspruch auf permanente Verfügbarkeit oder Vollständigkeit.",
|
||||||
|
"projectIdea": "Die Idee hinter Hördle ist, Musik spielerisch neu zu entdecken und Lieblingssongs wiederzuentdecken – inspiriert von Wordle, aber für Musikfans.",
|
||||||
|
"imprintTitle": "Impressum",
|
||||||
|
"imprintOperator": "Verantwortlich für den Inhalt dieser Seite (Anbieter nach § 5 TMG):",
|
||||||
|
"imprintCountry": "Deutschland",
|
||||||
|
"imprintEmailLabel": "E-Mail:",
|
||||||
|
"imprintDisclaimer": "Hinweis: Diese Angaben entsprechen dem aktuellen Stand. Für rechtliche Fragen solltest du eine Fachperson konsultieren.",
|
||||||
|
"costsTitle": "Laufende Kosten des Projekts",
|
||||||
|
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||||
|
"costsDomain": "Domains (z. B. hördle.de / hoerdle.de)",
|
||||||
|
"costsServer": "Server / vServer für App und Tracking",
|
||||||
|
"costsEmail": "E-Mail-Hosting",
|
||||||
|
"costsLicenses": "ggf. Gebühren für Urheberrechte oder andere Lizenzen",
|
||||||
|
"costsSheetLinkText": "Eine detaillierte, laufend gepflegte Übersicht über die aktuellen Kosten findest du in dieser <link>Google-Tabelle</link>.",
|
||||||
|
"costsSheetPrivacyNote": "Beim Aufruf oder Einbetten der Google-Tabelle können Daten (z. B. deine IP-Adresse) an Google übermittelt werden. Wenn du das nicht möchtest, öffne die Tabelle nicht.",
|
||||||
|
"supportTitle": "Hördle unterstützen",
|
||||||
|
"supportIntro": "Hördle ist ein nicht-kommerzielles Projekt, das von laufenden Kosten finanziert werden muss. Wenn du das Projekt finanziell unterstützen möchtest, gibt es folgende Möglichkeiten:",
|
||||||
|
"supportSepaTitle": "SEPA Banküberweisung (bevorzugt)",
|
||||||
|
"supportSepaName": "Markus Busche",
|
||||||
|
"supportSepaIban": "IBAN: DE28500310001071584000",
|
||||||
|
"supportPaypalTitle": "PayPal Spende",
|
||||||
|
"supportPaypalLink": "paypal.me/MBusche",
|
||||||
|
"supportSteadyTitle": "Steady",
|
||||||
|
"supportSteadyDescription": "Regelmäßige Unterstützung über Steady",
|
||||||
|
"privacyTitle": "Datenschutz",
|
||||||
|
"privacyIntro": "Der Schutz deiner Privatsphäre ist wichtig. Dieses Projekt versucht, so datensparsam wie möglich zu arbeiten.",
|
||||||
|
"privacyPlausibleTitle": "Selbst gehostetes Plausible Analytics",
|
||||||
|
"privacyPlausibleSelfHosted": "Für anonyme Nutzungsstatistiken wird Plausible Analytics auf einem selbst gehosteten Server verwendet. Es werden keine personalisierten Profile erstellt.",
|
||||||
|
"privacyPlausibleGemaTariff": "Das Tracking ist erforderlich, um den passenden GEMA Tarif zu bestimmen.",
|
||||||
|
"privacyPlausibleNoCookies": "Es werden keine Cookies für das Tracking gesetzt.",
|
||||||
|
"privacyPlausibleNoTrackingAcrossSites": "Es findet kein Tracking über mehrere Webseiten oder Geräte hinweg statt.",
|
||||||
|
"privacyPlausibleAggregated": "Auswertungen erfolgen ausschließlich in aggregierter Form (z. B. Seitenaufrufe, genutzte Browser).",
|
||||||
|
"privacyServerLogs": "Der Server kann technisch bedingt Logdateien mit IP-Adresse, Zeitpunkt des Zugriffs und abgerufenen Ressourcen führen. Diese Daten werden nur zur Sicherstellung des Betriebs und zur Fehleranalyse verwendet und regelmäßig gelöscht.",
|
||||||
|
"privacyContact": "Wenn du Fragen zu den verarbeiteten Daten hast oder Auskunft wünschst, kannst du dich über die im Impressum genannte E-Mail-Adresse melden.",
|
||||||
|
"privacyNoLegalAdvice": "Hinweis: Diese Datenschutzhinweise dienen nur als Beispiel und ersetzen keine rechtliche Beratung. Für eine rechtskonforme Datenschutzerklärung solltest du eine Fachperson konsultieren.",
|
||||||
|
"backTitle": "Zurück zum Spiel",
|
||||||
|
"backToGame": "Zurück zu Hördle",
|
||||||
|
"footerLinkLabel": "Über & Impressum"
|
||||||
|
}
|
||||||
|
}
|
||||||
198
messages/en.json
Normal file
198
messages/en.json
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
{
|
||||||
|
"Common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "An error occurred",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"back": "Back"
|
||||||
|
},
|
||||||
|
"Navigation": {
|
||||||
|
"home": "Home",
|
||||||
|
"admin": "Admin",
|
||||||
|
"global": "Global",
|
||||||
|
"news": "News"
|
||||||
|
},
|
||||||
|
"Game": {
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"skip": "Skip",
|
||||||
|
"submit": "Guess",
|
||||||
|
"next": "Next",
|
||||||
|
"won": "You won!",
|
||||||
|
"lost": "Game Over",
|
||||||
|
"correct": "Correct!",
|
||||||
|
"wrong": "Wrong",
|
||||||
|
"guessPlaceholder": "Type song or artist...",
|
||||||
|
"attempts": "Attempts",
|
||||||
|
"share": "Share",
|
||||||
|
"nextPuzzle": "Next puzzle in",
|
||||||
|
"noPuzzleAvailable": "No Puzzle Available",
|
||||||
|
"noPuzzleDescription": "Could not generate a daily puzzle.",
|
||||||
|
"noPuzzleGenre": "Please ensure there are songs in the database",
|
||||||
|
"goToAdmin": "Go to Admin Dashboard",
|
||||||
|
"loadingState": "Loading state...",
|
||||||
|
"attempt": "Attempt",
|
||||||
|
"unlocked": "unlocked",
|
||||||
|
"start": "Start",
|
||||||
|
"skipWithBonus": "Skip (+{seconds}s)",
|
||||||
|
"solveGiveUp": "Solve (Give Up)",
|
||||||
|
"comeBackTomorrow": "Come back tomorrow for a new song.",
|
||||||
|
"theSongWas": "The song was:",
|
||||||
|
"score": "Score",
|
||||||
|
"scoreBreakdown": "Score Breakdown",
|
||||||
|
"albumCover": "Album Cover",
|
||||||
|
"released": "Released",
|
||||||
|
"yourBrowserDoesNotSupport": "Your browser does not support the audio element.",
|
||||||
|
"thanksForRating": "Thanks for rating!",
|
||||||
|
"rateThisPuzzle": "Rate this puzzle:",
|
||||||
|
"shared": "✓ Shared!",
|
||||||
|
"copied": "✓ Copied!",
|
||||||
|
"shareFailed": "✗ Failed",
|
||||||
|
"bonusRound": "Bonus Round!",
|
||||||
|
"guessReleaseYear": "Guess the release year for",
|
||||||
|
"points": "points",
|
||||||
|
"skipBonus": "Skip Bonus",
|
||||||
|
"notQuite": "Not quite!",
|
||||||
|
"youGuessed": "You guessed",
|
||||||
|
"actuallyReleasedIn": "Actually released in",
|
||||||
|
"skipped": "Skipped",
|
||||||
|
"gameOverPlaceholder": "Game Over",
|
||||||
|
"knowItSearch": "Know it? Search for the artist / title",
|
||||||
|
"special": "Special",
|
||||||
|
"genre": "Genre"
|
||||||
|
},
|
||||||
|
"Statistics": {
|
||||||
|
"yourStatistics": "Your Statistics",
|
||||||
|
"totalPuzzles": "Total puzzles",
|
||||||
|
"try": "try",
|
||||||
|
"failed": "Failed"
|
||||||
|
},
|
||||||
|
"OnboardingTour": {
|
||||||
|
"done": "Done",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"genresSpecials": "Genres & Specials",
|
||||||
|
"genresSpecialsDescription": "Choose a specific genre or a curated special event here.",
|
||||||
|
"news": "News",
|
||||||
|
"newsDescription": "Stay updated with the latest news and announcements.",
|
||||||
|
"hoerdle": "Hördle",
|
||||||
|
"hoerdleDescription": "This is the daily puzzle. One new song every day per genre.",
|
||||||
|
"attempts": "Attempts",
|
||||||
|
"attemptsDescription": "You have a limited number of attempts to guess the song.",
|
||||||
|
"score": "Score",
|
||||||
|
"scoreDescription": "Your current score. Try to keep it high!",
|
||||||
|
"player": "Player",
|
||||||
|
"playerDescription": "Listen to the snippet. Each additional play reduces your potential score.",
|
||||||
|
"input": "Input",
|
||||||
|
"inputDescription": "Type your guess here. Search for artist or title.",
|
||||||
|
"controls": "Controls",
|
||||||
|
"controlsDescription": "Start the music or skip to the next snippet if you're stuck."
|
||||||
|
},
|
||||||
|
"InstallPrompt": {
|
||||||
|
"installApp": "Install Hördle App",
|
||||||
|
"installDescription": "Install the app for a better experience and quick access!",
|
||||||
|
"iosInstructions": "Tap",
|
||||||
|
"iosShare": "share",
|
||||||
|
"iosThen": "then \"Add to Home Screen\"",
|
||||||
|
"installButton": "Install App"
|
||||||
|
},
|
||||||
|
"Home": {
|
||||||
|
"welcome": "Welcome to Hördle",
|
||||||
|
"subtitle": "Guess the song from short snippets",
|
||||||
|
"globalTooltip": "A random song from the entire collection",
|
||||||
|
"comingSoon": "Coming soon",
|
||||||
|
"curatedBy": "Curated by"
|
||||||
|
},
|
||||||
|
"Admin": {
|
||||||
|
"title": "Hördle Admin Dashboard",
|
||||||
|
"login": "Admin Login",
|
||||||
|
"password": "Password",
|
||||||
|
"loginButton": "Login",
|
||||||
|
"logout": "Logout",
|
||||||
|
"manageSpecials": "Manage Specials",
|
||||||
|
"manageGenres": "Manage Genres",
|
||||||
|
"manageNews": "Manage News & Announcements",
|
||||||
|
"uploadSongs": "Upload Songs",
|
||||||
|
"todaysPuzzles": "Today's Daily Puzzles",
|
||||||
|
"show": "▶ Show",
|
||||||
|
"hide": "▼ Hide",
|
||||||
|
"addSpecial": "Add Special",
|
||||||
|
"addGenre": "Add Genre",
|
||||||
|
"addNews": "Add News",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"curate": "Curate",
|
||||||
|
"name": "Name",
|
||||||
|
"subtitle": "Subtitle",
|
||||||
|
"maxAttempts": "Max Attempts",
|
||||||
|
"unlockSteps": "Unlock Steps",
|
||||||
|
"launchDate": "Launch Date",
|
||||||
|
"endDate": "End Date",
|
||||||
|
"curator": "Curator",
|
||||||
|
"active": "Active",
|
||||||
|
"newGenreName": "New Genre Name",
|
||||||
|
"editSpecial": "Edit Special",
|
||||||
|
"editGenre": "Edit Genre",
|
||||||
|
"editNews": "Edit News",
|
||||||
|
"newsTitle": "News Title",
|
||||||
|
"content": "Content (Markdown supported)",
|
||||||
|
"author": "Author (optional)",
|
||||||
|
"featured": "Featured",
|
||||||
|
"noSpecialLink": "No Special Link",
|
||||||
|
"noNewsItems": "No news items yet. Create one above!",
|
||||||
|
"noPuzzlesToday": "No daily puzzles found for today.",
|
||||||
|
"category": "Category",
|
||||||
|
"song": "Song",
|
||||||
|
"artist": "Artist",
|
||||||
|
"actions": "Actions",
|
||||||
|
"deletePuzzle": "Delete",
|
||||||
|
"wrongPassword": "Wrong password"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "About Hördle & Imprint",
|
||||||
|
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",
|
||||||
|
"projectTitle": "About this project",
|
||||||
|
"projectPrivateNote": "Hördle is developed, operated, curated and financed privately in the creator's spare time. There is no guarantee for permanent availability or completeness.",
|
||||||
|
"projectIdea": "The idea behind Hördle is to (re)discover music in a playful way – inspired by Wordle, but for music lovers.",
|
||||||
|
"imprintTitle": "Imprint",
|
||||||
|
"imprintOperator": "Responsible for the content of this site (provider under German law):",
|
||||||
|
"imprintCountry": "Germany",
|
||||||
|
"imprintEmailLabel": "Email:",
|
||||||
|
"imprintDisclaimer": "Note: This information is current as of the date indicated. For legal questions you should consult a legal professional.",
|
||||||
|
"costsTitle": "Ongoing costs of the project",
|
||||||
|
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||||
|
"costsDomain": "Domains (e.g. hördle.de / hoerdle.de)",
|
||||||
|
"costsServer": "Servers / vServers for the app and tracking",
|
||||||
|
"costsEmail": "Email hosting",
|
||||||
|
"costsLicenses": "Possible fees for copyrights or other licenses",
|
||||||
|
"costsSheetLinkText": "You can find a detailed, continuously updated overview of the current costs in this <link>Google Sheet</link>.",
|
||||||
|
"costsSheetPrivacyNote": "When accessing or embedding the Google Sheet, data (e.g. your IP address) may be transmitted to Google. If you don't want that, please do not open the sheet.",
|
||||||
|
"supportTitle": "Support Hördle",
|
||||||
|
"supportIntro": "Hördle is a non-commercial project that needs to be financed by ongoing costs. If you would like to support the project financially, here are the options:",
|
||||||
|
"supportSepaTitle": "SEPA Bank Transfer (preferred)",
|
||||||
|
"supportSepaName": "Markus Busche",
|
||||||
|
"supportSepaIban": "IBAN: DE28500310001071584000",
|
||||||
|
"supportPaypalTitle": "PayPal Donation",
|
||||||
|
"supportPaypalLink": "paypal.me/MBusche",
|
||||||
|
"supportSteadyTitle": "Steady",
|
||||||
|
"supportSteadyDescription": "Regular support via Steady",
|
||||||
|
"privacyTitle": "Privacy",
|
||||||
|
"privacyIntro": "Protecting your privacy matters. This project aims to collect as little data as possible.",
|
||||||
|
"privacyPlausibleTitle": "Self-hosted Plausible Analytics",
|
||||||
|
"privacyPlausibleSelfHosted": "For anonymous usage statistics, Plausible Analytics is used on a self-hosted server. No personal profiles are created.",
|
||||||
|
"privacyPlausibleGemaTariff": "Tracking is required to determine the appropriate GEMA tariff.",
|
||||||
|
"privacyPlausibleNoCookies": "No cookies are set for analytics purposes.",
|
||||||
|
"privacyPlausibleNoTrackingAcrossSites": "There is no tracking across multiple websites or devices.",
|
||||||
|
"privacyPlausibleAggregated": "Analytics are only performed in aggregated form (e.g. page views, browsers used).",
|
||||||
|
"privacyServerLogs": "For technical reasons, the server may log IP address, time of access and accessed resources. This data is only used to keep the service running and to debug issues and is deleted on a regular basis.",
|
||||||
|
"privacyContact": "If you have questions about the data processed or want to request information, please contact the email address given in the imprint.",
|
||||||
|
"privacyNoLegalAdvice": "Note: These privacy notes are only an example and do not replace legal advice. For a legally compliant privacy policy you should consult a professional.",
|
||||||
|
"backTitle": "Back to the game",
|
||||||
|
"backToGame": "Back to Hördle",
|
||||||
|
"footerLinkLabel": "About & Imprint"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
reactCompiler: true,
|
reactCompiler: true,
|
||||||
@@ -8,7 +11,7 @@ const nextConfig: NextConfig = {
|
|||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
middlewareClientMaxBodySize: '50mb',
|
proxyClientMaxBodySize: '50mb',
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
TZ: process.env.TZ || 'Europe/Berlin',
|
TZ: process.env.TZ || 'Europe/Berlin',
|
||||||
@@ -36,4 +39,4 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
588
package-lock.json
generated
588
package-lock.json
generated
@@ -1,21 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"music-metadata": "^11.10.2",
|
"music-metadata": "^11.10.2",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"next-intl": "^4.5.6",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-markdown": "^10.1.0"
|
"react-markdown": "^10.1.0",
|
||||||
|
"unist-util-visit-parents": "^6.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@@ -23,6 +26,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"baseline-browser-mapping": "^2.8.32",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -395,9 +399,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc": {
|
"node_modules/@eslint/eslintrc": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
|
||||||
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
|
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -407,7 +411,7 @@
|
|||||||
"globals": "^14.0.0",
|
"globals": "^14.0.0",
|
||||||
"ignore": "^5.2.0",
|
"ignore": "^5.2.0",
|
||||||
"import-fresh": "^3.2.1",
|
"import-fresh": "^3.2.1",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.1",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^3.1.2",
|
||||||
"strip-json-comments": "^3.1.1"
|
"strip-json-comments": "^3.1.1"
|
||||||
},
|
},
|
||||||
@@ -455,6 +459,66 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@formatjs/ecma402-abstract": {
|
||||||
|
"version": "2.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz",
|
||||||
|
"integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/fast-memoize": "2.2.7",
|
||||||
|
"@formatjs/intl-localematcher": "0.6.2",
|
||||||
|
"decimal.js": "^10.4.3",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/fast-memoize": {
|
||||||
|
"version": "2.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz",
|
||||||
|
"integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/icu-messageformat-parser": {
|
||||||
|
"version": "2.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz",
|
||||||
|
"integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/ecma402-abstract": "2.3.6",
|
||||||
|
"@formatjs/icu-skeleton-parser": "1.8.16",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/icu-skeleton-parser": {
|
||||||
|
"version": "1.8.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz",
|
||||||
|
"integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/ecma402-abstract": "2.3.6",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@formatjs/intl-localematcher": {
|
||||||
|
"version": "0.5.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz",
|
||||||
|
"integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -1314,12 +1378,184 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@schummar/icu-type-parser": {
|
||||||
|
"version": "1.21.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
|
||||||
|
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-AXfeQn0CvcQ4cndlIshETx6jrAM45oeUrK8YeEY6oUZU/qzz0Id0CyvlEywxkWVC81Ajpd8TQQ1fW5yx6zQWkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-darwin-x64": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-p68OeCz1ui+MZYG4wmfJGvcsAcFYb6Sl25H9TxWl+GkBgmNimIiRdnypK9nBGlqMZAcxngNPtnG3kEMNnvoJ2A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-Nuj5iF4JteFgwrai97mUX+xUOl+rQRHqTvnvHMATL/l9xE6/TJfPBpd3hk/PVpClMXG3Uvk1MxUFOEzM1JrMYg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-2Nc/s8jE6mW2EjXWxO/lyQuLKShcmTrym2LRf5Ayp3ICEMX6HwFqB1EzDhwoMa2DcUgmnZIalesq2lG3krrUNw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-arm64-musl": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-gnu": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-linux-x64-musl": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-B8UtogMzErUPDWUoKONSVBdsgKYd58rRyv2sHJWKOIMCHfZ22FVXICR4O/VwIYtlnZ7ahERcjayBHDlBZpR0aw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/core-win32-x64-msvc": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-SpZKMR9QBTecHeqpzJdYEfgw30Oo8b/Xl6rjSzBt1g0ZsXyy60KLXrp6IagQyfTYqNYE/caDvwtF2FPn7pomog==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@swc/counter": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -1329,6 +1565,15 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@swc/types": {
|
||||||
|
"version": "0.1.25",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz",
|
||||||
|
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tokenizer/inflate": {
|
"node_modules/@tokenizer/inflate": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||||
@@ -1443,9 +1688,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -1468,17 +1713,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz",
|
||||||
"integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==",
|
"integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.10.0",
|
"@eslint-community/regexpp": "^4.10.0",
|
||||||
"@typescript-eslint/scope-manager": "8.47.0",
|
"@typescript-eslint/scope-manager": "8.48.0",
|
||||||
"@typescript-eslint/type-utils": "8.47.0",
|
"@typescript-eslint/type-utils": "8.48.0",
|
||||||
"@typescript-eslint/utils": "8.47.0",
|
"@typescript-eslint/utils": "8.48.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.47.0",
|
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||||
"graphemer": "^1.4.0",
|
"graphemer": "^1.4.0",
|
||||||
"ignore": "^7.0.0",
|
"ignore": "^7.0.0",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
@@ -1492,7 +1737,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.47.0",
|
"@typescript-eslint/parser": "^8.48.0",
|
||||||
"eslint": "^8.57.0 || ^9.0.0",
|
"eslint": "^8.57.0 || ^9.0.0",
|
||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
@@ -1508,16 +1753,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz",
|
||||||
"integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==",
|
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.47.0",
|
"@typescript-eslint/scope-manager": "8.48.0",
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.47.0",
|
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.47.0",
|
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1533,14 +1778,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz",
|
||||||
"integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==",
|
"integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.47.0",
|
"@typescript-eslint/tsconfig-utils": "^8.48.0",
|
||||||
"@typescript-eslint/types": "^8.47.0",
|
"@typescript-eslint/types": "^8.48.0",
|
||||||
"debug": "^4.3.4"
|
"debug": "^4.3.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1555,14 +1800,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz",
|
||||||
"integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==",
|
"integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.47.0"
|
"@typescript-eslint/visitor-keys": "8.48.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1573,9 +1818,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz",
|
||||||
"integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==",
|
"integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1590,15 +1835,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz",
|
||||||
"integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==",
|
"integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.47.0",
|
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||||
"@typescript-eslint/utils": "8.47.0",
|
"@typescript-eslint/utils": "8.48.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"ts-api-utils": "^2.1.0"
|
"ts-api-utils": "^2.1.0"
|
||||||
},
|
},
|
||||||
@@ -1615,9 +1860,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz",
|
||||||
"integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==",
|
"integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1629,21 +1874,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz",
|
||||||
"integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==",
|
"integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.47.0",
|
"@typescript-eslint/project-service": "8.48.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.47.0",
|
"@typescript-eslint/tsconfig-utils": "8.48.0",
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.47.0",
|
"@typescript-eslint/visitor-keys": "8.48.0",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"fast-glob": "^3.3.2",
|
|
||||||
"is-glob": "^4.0.3",
|
|
||||||
"minimatch": "^9.0.4",
|
"minimatch": "^9.0.4",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
"ts-api-utils": "^2.1.0"
|
"ts-api-utils": "^2.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1667,36 +1911,6 @@
|
|||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/fast-glob": {
|
|
||||||
"version": "3.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
|
||||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@nodelib/fs.stat": "^2.0.2",
|
|
||||||
"@nodelib/fs.walk": "^1.2.3",
|
|
||||||
"glob-parent": "^5.1.2",
|
|
||||||
"merge2": "^1.3.0",
|
|
||||||
"micromatch": "^4.0.8"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/glob-parent": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"is-glob": "^4.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||||
@@ -1727,16 +1941,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz",
|
||||||
"integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==",
|
"integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.7.0",
|
"@eslint-community/eslint-utils": "^4.7.0",
|
||||||
"@typescript-eslint/scope-manager": "8.47.0",
|
"@typescript-eslint/scope-manager": "8.48.0",
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.47.0"
|
"@typescript-eslint/typescript-estree": "8.48.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1751,13 +1965,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz",
|
||||||
"integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==",
|
"integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.47.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
"eslint-visitor-keys": "^4.2.1"
|
"eslint-visitor-keys": "^4.2.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2357,9 +2571,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.30",
|
"version": "2.8.32",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.32.tgz",
|
||||||
"integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==",
|
"integrity": "sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2522,9 +2736,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001756",
|
"version": "1.0.30001757",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
||||||
"integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==",
|
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -2805,6 +3019,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||||
@@ -2939,6 +3159,12 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/driver.js": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -2965,9 +3191,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.259",
|
"version": "1.5.262",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
|
||||||
"integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==",
|
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -4264,6 +4490,18 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/intl-messageformat": {
|
||||||
|
"version": "10.7.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz",
|
||||||
|
"integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/ecma402-abstract": "2.3.6",
|
||||||
|
"@formatjs/fast-memoize": "2.2.7",
|
||||||
|
"@formatjs/icu-messageformat-parser": "2.11.4",
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -5627,9 +5865,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/music-metadata": {
|
"node_modules/music-metadata": {
|
||||||
"version": "11.10.2",
|
"version": "11.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.3.tgz",
|
||||||
"integrity": "sha512-sGbF+Si+GqYJGO6qQDyfvesfxb1M49m0QjLLkGR5zoRlPaZtHRvo8DDI5R/vySJVtUzTQ6Lwfd7nspYpmmInsA==",
|
"integrity": "sha512-j0g/x4cNNZW6I5gdcPAY+GFkJY9WHTpkFDMBJKQLxJQyvSfQbXm57fTE3haGFFuOzCgtsTd4Plwc49Sn9RacDQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5697,6 +5935,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.0.3",
|
"version": "16.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz",
|
||||||
@@ -5749,6 +5996,91 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-intl": {
|
||||||
|
"version": "4.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.5.6.tgz",
|
||||||
|
"integrity": "sha512-LD1mM9HL44NGqDus3cpIE8wqRU87GWf7rdy1g7UHceT9KJvvjER/jlmIRt3GHaoOiln16K4IbHpO2ZI6jiqiDQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/amannn"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/intl-localematcher": "^0.5.4",
|
||||||
|
"@swc/core": "^1.15.2",
|
||||||
|
"negotiator": "^1.0.0",
|
||||||
|
"next-intl-swc-plugin-extractor": "^4.5.6",
|
||||||
|
"po-parser": "^1.0.2",
|
||||||
|
"use-intl": "^4.5.6"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-intl-swc-plugin-extractor": {
|
||||||
|
"version": "4.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.5.6.tgz",
|
||||||
|
"integrity": "sha512-ApB3wGYqni8lks90UuaslnCK4a+q8I6ajEafSpknN6RDrs2hUwNuWVrjKhOuhLqNLn4kBKl+Zi5c0WKpL968ag==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@swc/core": {
|
||||||
|
"version": "1.15.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.3.tgz",
|
||||||
|
"integrity": "sha512-Qd8eBPkUFL4eAONgGjycZXj1jFCBW8Fd+xF0PzdTlBCWQIV1xnUT7B93wUANtW3KGjl3TRcOyxwSx/u/jyKw/Q==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@swc/counter": "^0.1.3",
|
||||||
|
"@swc/types": "^0.1.25"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/swc"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@swc/core-darwin-arm64": "1.15.3",
|
||||||
|
"@swc/core-darwin-x64": "1.15.3",
|
||||||
|
"@swc/core-linux-arm-gnueabihf": "1.15.3",
|
||||||
|
"@swc/core-linux-arm64-gnu": "1.15.3",
|
||||||
|
"@swc/core-linux-arm64-musl": "1.15.3",
|
||||||
|
"@swc/core-linux-x64-gnu": "1.15.3",
|
||||||
|
"@swc/core-linux-x64-musl": "1.15.3",
|
||||||
|
"@swc/core-win32-arm64-msvc": "1.15.3",
|
||||||
|
"@swc/core-win32-ia32-msvc": "1.15.3",
|
||||||
|
"@swc/core-win32-x64-msvc": "1.15.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/helpers": ">=0.5.17"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/helpers": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-fetch-native": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
@@ -6085,6 +6417,12 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/po-parser": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-yTIQL8PZy7V8c0psPoJUx7fayez+Mo/53MZgX9MPuPHx+Dt+sRPNuRbI+6Oqxnddhkd68x4Nlgon/zizL1Xg+w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -7277,16 +7615,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.47.0",
|
"version": "8.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.0.tgz",
|
||||||
"integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==",
|
"integrity": "sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.47.0",
|
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||||
"@typescript-eslint/parser": "8.47.0",
|
"@typescript-eslint/parser": "8.48.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.47.0",
|
"@typescript-eslint/typescript-estree": "8.48.0",
|
||||||
"@typescript-eslint/utils": "8.47.0"
|
"@typescript-eslint/utils": "8.48.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -7501,6 +7839,20 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-intl": {
|
||||||
|
"version": "4.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.5.6.tgz",
|
||||||
|
"integrity": "sha512-SzxrUH/X3LatVcgWVqz8ifoBK01LC3fzc8Y29Vj0QfrjLIXfGwxvJ3aapyWumBIIHsZmCR0Rx5FpKDWCc9JiOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@formatjs/fast-memoize": "^2.2.0",
|
||||||
|
"@schummar/icu-type-parser": "1.21.5",
|
||||||
|
"intl-messageformat": "^10.5.14"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vfile": {
|
"node_modules/vfile": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
@@ -7665,9 +8017,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
|
||||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -11,12 +11,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"music-metadata": "^11.10.2",
|
"music-metadata": "^11.10.2",
|
||||||
"next": "16.0.3",
|
"next": "16.0.3",
|
||||||
|
"next-intl": "^4.5.6",
|
||||||
"prisma": "^6.19.0",
|
"prisma": "^6.19.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-markdown": "^10.1.0"
|
"react-markdown": "^10.1.0",
|
||||||
|
"unist-util-visit-parents": "^6.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"baseline-browser-mapping": "^2.8.32",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.3",
|
"eslint-config-next": "16.0.3",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Genre" ADD COLUMN "nameI18n" JSONB;
|
||||||
|
ALTER TABLE "Genre" ADD COLUMN "subtitleI18n" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "News" ADD COLUMN "contentI18n" JSONB;
|
||||||
|
ALTER TABLE "News" ADD COLUMN "titleI18n" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Special" ADD COLUMN "nameI18n" JSONB;
|
||||||
|
ALTER TABLE "Special" ADD COLUMN "subtitleI18n" JSONB;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `nameI18n` on the `Genre` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `subtitleI18n` on the `Genre` table. All the data in the column will be lost.
|
||||||
|
- You are about to alter the column `name` on the `Genre` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
|
||||||
|
- You are about to alter the column `subtitle` on the `Genre` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
|
||||||
|
- You are about to drop the column `contentI18n` on the `News` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `titleI18n` on the `News` table. All the data in the column will be lost.
|
||||||
|
- You are about to alter the column `content` on the `News` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
|
||||||
|
- You are about to alter the column `title` on the `News` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
|
||||||
|
- You are about to drop the column `nameI18n` on the `Special` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `subtitleI18n` on the `Special` table. All the data in the column will be lost.
|
||||||
|
- You are about to alter the column `name` on the `Special` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
|
||||||
|
- You are about to alter the column `subtitle` on the `Special` table. The data in that column could be lost. The data in that column will be cast from `String` to `Json`.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Genre" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" JSONB NOT NULL,
|
||||||
|
"subtitle" JSONB,
|
||||||
|
"active" BOOLEAN NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Genre" ("active", "id", "name", "subtitle") SELECT "active", "id", "nameI18n", "subtitleI18n" FROM "Genre";
|
||||||
|
DROP TABLE "Genre";
|
||||||
|
ALTER TABLE "new_Genre" RENAME TO "Genre";
|
||||||
|
CREATE TABLE "new_News" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"title" JSONB NOT NULL,
|
||||||
|
"content" JSONB NOT NULL,
|
||||||
|
"author" TEXT,
|
||||||
|
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"featured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"specialId" INTEGER,
|
||||||
|
CONSTRAINT "News_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_News" ("author", "content", "featured", "id", "publishedAt", "specialId", "title", "updatedAt") SELECT "author", "contentI18n", "featured", "id", "publishedAt", "specialId", "titleI18n", "updatedAt" FROM "News";
|
||||||
|
DROP TABLE "News";
|
||||||
|
ALTER TABLE "new_News" RENAME TO "News";
|
||||||
|
CREATE INDEX "News_publishedAt_idx" ON "News"("publishedAt");
|
||||||
|
CREATE TABLE "new_Special" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"name" JSONB NOT NULL,
|
||||||
|
"subtitle" JSONB,
|
||||||
|
"maxAttempts" INTEGER NOT NULL DEFAULT 7,
|
||||||
|
"unlockSteps" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"launchDate" DATETIME,
|
||||||
|
"endDate" DATETIME,
|
||||||
|
"curator" TEXT
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Special" ("createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps") SELECT "createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "nameI18n", "subtitleI18n", "unlockSteps" FROM "Special";
|
||||||
|
DROP TABLE "Special";
|
||||||
|
ALTER TABLE "new_Special" RENAME TO "Special";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -28,8 +28,8 @@ model Song {
|
|||||||
|
|
||||||
model Genre {
|
model Genre {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name Json // Multilingual: { "de": "Rock", "en": "Rock" }
|
||||||
subtitle String?
|
subtitle Json? // Multilingual
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
songs Song[]
|
songs Song[]
|
||||||
dailyPuzzles DailyPuzzle[]
|
dailyPuzzles DailyPuzzle[]
|
||||||
@@ -37,8 +37,8 @@ model Genre {
|
|||||||
|
|
||||||
model Special {
|
model Special {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String @unique
|
name Json // Multilingual
|
||||||
subtitle String?
|
subtitle Json? // Multilingual
|
||||||
maxAttempts Int @default(7)
|
maxAttempts Int @default(7)
|
||||||
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
|
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -77,8 +77,8 @@ model DailyPuzzle {
|
|||||||
|
|
||||||
model News {
|
model News {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
title String
|
title Json // Multilingual
|
||||||
content String // Markdown format
|
content Json // Multilingual
|
||||||
author String? // Optional: curator/admin name
|
author String? // Optional: curator/admin name
|
||||||
publishedAt DateTime @default(now())
|
publishedAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import createMiddleware from 'next-intl/middleware';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
const i18nMiddleware = createMiddleware({
|
||||||
const response = NextResponse.next();
|
locales: ['de', 'en'],
|
||||||
|
defaultLocale: 'de',
|
||||||
|
// Wir nutzen überall Locale-Präfixe (`/de`, `/en`)
|
||||||
|
localePrefix: 'always'
|
||||||
|
});
|
||||||
|
|
||||||
// Security Headers
|
export default function proxy(request: NextRequest) {
|
||||||
|
// 1. i18n-Routing
|
||||||
|
const response = i18nMiddleware(request);
|
||||||
|
|
||||||
|
// 2. Security-Header ergänzen
|
||||||
const headers = response.headers;
|
const headers = response.headers;
|
||||||
|
|
||||||
// Prevent clickjacking
|
|
||||||
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
|
||||||
// XSS Protection (legacy but still useful)
|
|
||||||
headers.set('X-XSS-Protection', '1; mode=block');
|
headers.set('X-XSS-Protection', '1; mode=block');
|
||||||
|
|
||||||
// Prevent MIME type sniffing
|
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
// Referrer Policy
|
|
||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
|
||||||
// Permissions Policy (restrict features)
|
|
||||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
|
||||||
// Content Security Policy
|
|
||||||
const csp = [
|
const csp = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me", // Next.js requires unsafe-inline/eval
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://plausible.elpatron.me",
|
||||||
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"font-src 'self' data:",
|
"font-src 'self' data:",
|
||||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
"connect-src 'self' https://openrouter.ai https://gotify.example.com https://plausible.elpatron.me",
|
||||||
@@ -38,15 +36,8 @@ export function middleware(request: NextRequest) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply middleware to all routes
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
// Empfohlener Matcher aus der next-intl Doku:
|
||||||
/*
|
// alle Routen außer _next, API und statischen Dateien
|
||||||
* Match all request paths except for the ones starting with:
|
matcher: ['/((?!api|_next|.*\\..*).*)']
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
*/
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
@@ -14,5 +14,10 @@ npx prisma migrate resolve --applied "20251123083856_add_rating_system"
|
|||||||
npx prisma migrate resolve --applied "20251123140527_add_subtitles"
|
npx prisma migrate resolve --applied "20251123140527_add_subtitles"
|
||||||
npx prisma migrate resolve --applied "20251123181922_add_release_year"
|
npx prisma migrate resolve --applied "20251123181922_add_release_year"
|
||||||
npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete"
|
npx prisma migrate resolve --applied "20251123204000_fix_cascade_delete"
|
||||||
|
npx prisma migrate resolve --applied "20251124182259_add_exclude_from_global"
|
||||||
|
npx prisma migrate resolve --applied "20251124231438_add_genre_active_field"
|
||||||
|
npx prisma migrate resolve --applied "20251125101602_add_news_model"
|
||||||
|
npx prisma migrate resolve --applied "20251128131405_add_i18n_columns"
|
||||||
|
npx prisma migrate resolve --applied "20251128132806_switch_to_json_columns"
|
||||||
|
|
||||||
echo "✅ Baseline complete! Restart the container to apply migrations normally."
|
echo "✅ Baseline complete! Restart the container to apply migrations normally."
|
||||||
|
|||||||
65
scripts/check-db-permissions.sh
Executable file
65
scripts/check-db-permissions.sh
Executable file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script zum Prüfen der Datenbank-Berechtigungen und User-Konfiguration
|
||||||
|
|
||||||
|
echo "🔍 Datenbank-Berechtigungen und User-Check"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe User im Container
|
||||||
|
echo "👤 User im Container:"
|
||||||
|
docker exec hoerdle whoami
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe UID/GID
|
||||||
|
echo "🆔 UID/GID des laufenden Prozesses:"
|
||||||
|
docker exec hoerdle id
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe Datenbankdatei
|
||||||
|
echo "💾 Datenbank-Datei-Informationen (im Container):"
|
||||||
|
docker exec hoerdle ls -lh /app/data/prod.db
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe Datenbankverzeichnis
|
||||||
|
echo "📁 Datenbankverzeichnis-Berechtigungen (im Container):"
|
||||||
|
docker exec hoerdle ls -ld /app/data
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob Datei schreibbar ist
|
||||||
|
echo "✍️ Schreibbarkeitstest:"
|
||||||
|
if docker exec hoerdle sh -c "test -w /app/data/prod.db && echo '✅ Datei ist schreibbar' || echo '❌ Datei ist NICHT schreibbar'"; then
|
||||||
|
echo " Datei ist schreibbar"
|
||||||
|
else
|
||||||
|
echo " ❌ Datei ist NICHT schreibbar!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prüfe ob Verzeichnis schreibbar ist
|
||||||
|
if docker exec hoerdle sh -c "test -w /app/data && echo '✅ Verzeichnis ist schreibbar' || echo '❌ Verzeichnis ist NICHT schreibbar'"; then
|
||||||
|
echo " Verzeichnis ist schreibbar"
|
||||||
|
else
|
||||||
|
echo " ❌ Verzeichnis ist NICHT schreibbar!"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe Host-Seite
|
||||||
|
echo "🖥️ Host-Seite Berechtigungen:"
|
||||||
|
ls -ld ./data
|
||||||
|
ls -lh ./data/prod.db
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe Container-User aus docker-compose
|
||||||
|
echo "🐳 Docker Compose Konfiguration:"
|
||||||
|
if [ -f "docker-compose.yml" ]; then
|
||||||
|
grep -E "^[[:space:]]*user:" docker-compose.yml || echo " Keine 'user:' Direktive gefunden"
|
||||||
|
else
|
||||||
|
echo " ⚠️ docker-compose.yml nicht gefunden (verwende example?)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Empfehlung
|
||||||
|
echo "💡 Empfehlung:"
|
||||||
|
echo " Wenn die Datei 'node:node' gehört und Container als 'root' läuft,"
|
||||||
|
echo " sollte es funktionieren. Falls nicht, setze Besitzer auf root:"
|
||||||
|
echo " sudo chown root:root ./data/prod.db"
|
||||||
|
echo " Oder entferne 'user: root' aus docker-compose.yml"
|
||||||
|
|
||||||
99
scripts/debug-server-error.sh
Executable file
99
scripts/debug-server-error.sh
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script zum Debuggen von Server-Errors in Hördle
|
||||||
|
# Zeigt relevante Logs und Status-Informationen
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔍 Hördle Server Error Debugging"
|
||||||
|
echo "=================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Container-Status prüfen
|
||||||
|
echo "📦 Container-Status:"
|
||||||
|
docker ps --filter "name=hoerdle" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob Container läuft
|
||||||
|
if ! docker ps | grep -q "hoerdle"; then
|
||||||
|
echo "❌ FEHLER: hoerdle Container läuft nicht!"
|
||||||
|
echo ""
|
||||||
|
echo "Versuche Container zu starten..."
|
||||||
|
docker compose up -d
|
||||||
|
sleep 5
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Letzte Logs anzeigen
|
||||||
|
echo "📋 Letzte 50 Zeilen der Container-Logs:"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
docker logs --tail=50 hoerdle 2>&1 | tail -50
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Suche nach Fehlern in den Logs
|
||||||
|
echo "🚨 Fehler in den Logs (letzte 100 Zeilen):"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
docker logs --tail=100 hoerdle 2>&1 | grep -i -E "error|exception|failed|fatal|panic" || echo "Keine offensichtlichen Fehler gefunden"
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Container Health Status
|
||||||
|
echo "💚 Health Check Status:"
|
||||||
|
docker inspect hoerdle --format='{{json .State.Health}}' | python3 -m json.tool 2>/dev/null || docker inspect hoerdle --format='{{.State.Status}}'
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob der Server auf Port 3000 antwortet (intern)
|
||||||
|
echo "🔌 Port-Verbindungstest (intern, Port 3000):"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
docker exec hoerdle curl -f -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://localhost:3000/api/daily 2>&1 || echo "❌ Verbindung fehlgeschlagen"
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe Datenbank
|
||||||
|
echo "💾 Datenbank-Status:"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
if docker exec hoerdle test -f /app/data/prod.db; then
|
||||||
|
echo "✅ Datenbankdatei existiert"
|
||||||
|
DB_SIZE=$(docker exec hoerdle stat -c%s /app/data/prod.db 2>/dev/null || echo "unbekannt")
|
||||||
|
echo " Größe: $DB_SIZE Bytes"
|
||||||
|
else
|
||||||
|
echo "❌ Datenbankdatei nicht gefunden!"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe Umgebungsvariablen (wichtige)
|
||||||
|
echo "🔐 Wichtige Umgebungsvariablen:"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
docker exec hoerdle env | grep -E "DATABASE_URL|NODE_ENV|PORT|HOSTNAME" || echo "Keine gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob Next.js Server läuft
|
||||||
|
echo "🌐 Next.js Prozess-Status:"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
docker exec hoerdle ps aux | grep -E "node|next" | grep -v grep || echo "Keine Next.js Prozesse gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Netzwerk-Verbindung prüfen
|
||||||
|
echo "🌐 Netzwerk-Verbindungen:"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
docker network inspect hoerdle_default --format='{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}' 2>/dev/null || echo "Netzwerk nicht gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Caddy Status (falls vorhanden)
|
||||||
|
if docker ps | grep -q "hoerdle-caddy"; then
|
||||||
|
echo "🚪 Caddy-Container Status:"
|
||||||
|
echo "----------------------------------------"
|
||||||
|
docker logs --tail=20 hoerdle-caddy 2>&1 | tail -20
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=================================="
|
||||||
|
echo "✅ Debug-Informationen gesammelt"
|
||||||
|
echo ""
|
||||||
|
echo "💡 Nächste Schritte:"
|
||||||
|
echo "1. Prüfe die Fehler-Logs oben"
|
||||||
|
echo "2. Prüfe ob die Datenbank erreichbar ist"
|
||||||
|
echo "3. Prüfe ob alle Umgebungsvariablen gesetzt sind"
|
||||||
|
echo "4. Bei weiteren Problemen: docker logs hoerdle --tail=200"
|
||||||
|
|
||||||
@@ -54,6 +54,19 @@ git pull
|
|||||||
echo "🏷️ Fetching git tags..."
|
echo "🏷️ Fetching git tags..."
|
||||||
git fetch --tags
|
git fetch --tags
|
||||||
|
|
||||||
|
# Prüfe und erstelle/repariere Netzwerk falls nötig
|
||||||
|
echo "🌐 Prüfe Docker-Netzwerk..."
|
||||||
|
if ! docker network ls | grep -q "hoerdle_default"; then
|
||||||
|
echo " Netzwerk existiert nicht, erstelle es..."
|
||||||
|
docker network create hoerdle_default
|
||||||
|
echo "✅ Netzwerk erstellt"
|
||||||
|
else
|
||||||
|
# Prüfe ob Netzwerk falsche Labels hat (wird durch external: true umgangen)
|
||||||
|
echo "✅ Netzwerk existiert bereits"
|
||||||
|
echo " (Hinweis: Falls Warnungen über falsche Labels erscheinen, verwende: ./scripts/fix-network.sh)"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
# Build new image in background (doesn't stop running container)
|
# Build new image in background (doesn't stop running container)
|
||||||
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
echo "🔨 Building new Docker image (this runs while app is still online)..."
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|||||||
71
scripts/docker-cleanup.sh
Executable file
71
scripts/docker-cleanup.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Docker Cleanup-Skript
|
||||||
|
# Räumt nicht verwendete Docker-Images, Container, Volumes und Build-Cache auf
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧹 Docker Cleanup..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Zeige aktuellen Speicherverbrauch
|
||||||
|
echo "📊 Aktueller Docker-Speicherverbrauch:"
|
||||||
|
docker system df
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Frage nach Bestätigung (falls interaktiv)
|
||||||
|
if [ -t 0 ]; then
|
||||||
|
echo "⚠️ Dies wird folgende Ressourcen entfernen:"
|
||||||
|
echo " - Alle nicht verwendeten Images"
|
||||||
|
echo " - Alle gestoppten Container"
|
||||||
|
echo " - Alle nicht verwendeten Netzwerke"
|
||||||
|
echo " - Build-Cache"
|
||||||
|
echo ""
|
||||||
|
echo "Möchtest du fortfahren? (j/n)"
|
||||||
|
read -r response
|
||||||
|
if [ "$response" != "j" ] && [ "$response" != "J" ]; then
|
||||||
|
echo "❌ Abgebrochen."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Entferne gestoppte Container
|
||||||
|
echo "🗑️ Entferne gestoppte Container..."
|
||||||
|
STOPPED_CONTAINERS=$(docker ps -a -q -f status=exited | wc -l)
|
||||||
|
if [ "$STOPPED_CONTAINERS" -gt 0 ]; then
|
||||||
|
docker container prune -f
|
||||||
|
echo "✅ Gestoppte Container entfernt"
|
||||||
|
else
|
||||||
|
echo " Keine gestoppten Container gefunden"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Entferne nicht verwendete Images
|
||||||
|
echo "🗑️ Entferne nicht verwendete Images..."
|
||||||
|
docker image prune -a -f
|
||||||
|
echo "✅ Nicht verwendete Images entfernt"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Entferne nicht verwendete Netzwerke
|
||||||
|
echo "🗑️ Entferne nicht verwendete Netzwerke..."
|
||||||
|
docker network prune -f
|
||||||
|
echo "✅ Nicht verwendete Netzwerke entfernt"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Entferne Build-Cache (optional, kann lange dauern)
|
||||||
|
echo "🗑️ Entferne Build-Cache..."
|
||||||
|
docker builder prune -f
|
||||||
|
echo "✅ Build-Cache entfernt"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Zeige neuen Speicherverbrauch
|
||||||
|
echo "📊 Neuer Docker-Speicherverbrauch:"
|
||||||
|
docker system df
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Zeige verfügbaren Speicherplatz
|
||||||
|
echo "💾 Verfügbarer Speicherplatz:"
|
||||||
|
df -h / | tail -1 | awk '{print " Gesamt: " $2 ", Verfügbar: " $4 ", Belegt: " $5}'
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "✅ Cleanup abgeschlossen!"
|
||||||
|
|
||||||
@@ -9,11 +9,23 @@ fi
|
|||||||
|
|
||||||
echo "Starting deployment..."
|
echo "Starting deployment..."
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations with fallback to baseline if needed
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
npx prisma migrate deploy
|
if ! npx prisma migrate deploy; then
|
||||||
|
echo "⚠️ Migration failed, attempting to baseline existing database..."
|
||||||
|
if [ -f /app/scripts/baseline-migrations.sh ]; then
|
||||||
|
sh /app/scripts/baseline-migrations.sh
|
||||||
|
echo "Retrying migrations after baseline..."
|
||||||
|
npx prisma migrate deploy || {
|
||||||
|
echo "❌ Migration failed even after baseline. Please check your database."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "❌ ERROR: Migration failed and baseline script not found at /app/scripts/baseline-migrations.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "✅ Migrations completed successfully"
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
echo "Starting application..."
|
echo "Starting application..."
|
||||||
|
|||||||
150
scripts/fix-i18n-local.sh
Executable file
150
scripts/fix-i18n-local.sh
Executable file
@@ -0,0 +1,150 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Fix für i18n-Daten: Kopiert DB lokal, fixt sie, kopiert zurück
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 Fixe i18n-Daten (lokal kopieren, fixen, zurück kopieren)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob Container läuft
|
||||||
|
if ! docker ps | grep -q hoerdle; then
|
||||||
|
echo "❌ Container 'hoerdle' läuft nicht!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup erstellen
|
||||||
|
BACKUP_FILE="./data/prod.db.backup.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
echo "💾 Erstelle Backup..."
|
||||||
|
docker cp hoerdle:/app/data/prod.db "$BACKUP_FILE"
|
||||||
|
# Setze Berechtigungen (kann root gehören)
|
||||||
|
sudo chmod 666 "$BACKUP_FILE" 2>/dev/null || chmod 666 "$BACKUP_FILE" 2>/dev/null || true
|
||||||
|
echo "✅ Backup erstellt: $BACKUP_FILE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Kopiere DB lokal
|
||||||
|
echo "📥 Kopiere Datenbank lokal..."
|
||||||
|
docker cp hoerdle:/app/data/prod.db ./data/prod.db.tmp
|
||||||
|
# Setze Berechtigungen (Datei kann root gehören)
|
||||||
|
sudo chmod 666 ./data/prod.db.tmp 2>/dev/null || chmod 666 ./data/prod.db.tmp 2>/dev/null || {
|
||||||
|
echo "⚠️ Konnte Berechtigungen nicht setzen. Versuche mit sudo..."
|
||||||
|
sudo chmod 666 ./data/prod.db.tmp
|
||||||
|
}
|
||||||
|
chmod 775 ./data 2>/dev/null || sudo chmod 775 ./data 2>/dev/null || true
|
||||||
|
echo "✅ Datenbank kopiert"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe ob sqlite3 verfügbar ist
|
||||||
|
if ! command -v sqlite3 &> /dev/null; then
|
||||||
|
echo "❌ sqlite3 ist nicht installiert!"
|
||||||
|
echo " Installiere es mit: sudo apt-get install sqlite3"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fixe die Datenbank
|
||||||
|
echo "🔧 Fixe i18n-Daten..."
|
||||||
|
# Stelle sicher, dass wir Schreibrechte haben (auch für WAL-Dateien)
|
||||||
|
chmod 666 ./data/prod.db.tmp 2>/dev/null || sudo chmod 666 ./data/prod.db.tmp
|
||||||
|
chmod 775 ./data 2>/dev/null || sudo chmod 775 ./data
|
||||||
|
# Prüfe ob wir die Datei lesen können
|
||||||
|
if [ ! -r "./data/prod.db.tmp" ]; then
|
||||||
|
echo "❌ Kann Datenbankdatei nicht lesen. Setze Besitzer..."
|
||||||
|
sudo chown $(whoami):$(whoami) ./data/prod.db.tmp || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Führe SQL-Befehle aus (mit sudo falls nötig)
|
||||||
|
if [ -r "./data/prod.db.tmp" ] && [ -w "./data" ]; then
|
||||||
|
sqlite3 ./data/prod.db.tmp << 'SQL'
|
||||||
|
-- Fix Genre.name
|
||||||
|
UPDATE Genre
|
||||||
|
SET name = json_object('de', name, 'en', name)
|
||||||
|
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix Genre.subtitle
|
||||||
|
UPDATE Genre
|
||||||
|
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||||
|
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix Special.name
|
||||||
|
UPDATE Special
|
||||||
|
SET name = json_object('de', name, 'en', name)
|
||||||
|
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix Special.subtitle
|
||||||
|
UPDATE Special
|
||||||
|
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||||
|
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix News.title
|
||||||
|
UPDATE News
|
||||||
|
SET title = json_object('de', title, 'en', title)
|
||||||
|
WHERE typeof(title) = 'text' AND title NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix News.content
|
||||||
|
UPDATE News
|
||||||
|
SET content = json_object('de', content, 'en', content)
|
||||||
|
WHERE typeof(content) = 'text' AND content NOT LIKE '{%';
|
||||||
|
|
||||||
|
SELECT '✅ Alle i18n-Daten wurden gefixt!' as status;
|
||||||
|
SQL
|
||||||
|
else
|
||||||
|
echo "❌ Kann Datenbankdatei nicht lesen oder schreiben!"
|
||||||
|
echo " Versuche mit sudo..."
|
||||||
|
sudo sqlite3 ./data/prod.db.tmp << 'SQL'
|
||||||
|
-- Fix Genre.name
|
||||||
|
UPDATE Genre
|
||||||
|
SET name = json_object('de', name, 'en', name)
|
||||||
|
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix Genre.subtitle
|
||||||
|
UPDATE Genre
|
||||||
|
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||||
|
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix Special.name
|
||||||
|
UPDATE Special
|
||||||
|
SET name = json_object('de', name, 'en', name)
|
||||||
|
WHERE typeof(name) = 'text' AND name NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix Special.subtitle
|
||||||
|
UPDATE Special
|
||||||
|
SET subtitle = json_object('de', subtitle, 'en', subtitle)
|
||||||
|
WHERE subtitle IS NOT NULL AND typeof(subtitle) = 'text' AND subtitle NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix News.title
|
||||||
|
UPDATE News
|
||||||
|
SET title = json_object('de', title, 'en', title)
|
||||||
|
WHERE typeof(title) = 'text' AND title NOT LIKE '{%';
|
||||||
|
|
||||||
|
-- Fix News.content
|
||||||
|
UPDATE News
|
||||||
|
SET content = json_object('de', content, 'en', content)
|
||||||
|
WHERE typeof(content) = 'text' AND content NOT LIKE '{%';
|
||||||
|
|
||||||
|
SELECT '✅ Alle i18n-Daten wurden gefixt!' as status;
|
||||||
|
SQL
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Fehler beim Fixen der Datenbank!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Datenbank gefixt"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Kopiere zurück
|
||||||
|
echo "📤 Kopiere gefixte Datenbank zurück..."
|
||||||
|
docker cp ./data/prod.db.tmp hoerdle:/app/data/prod.db
|
||||||
|
echo "✅ Datenbank zurück kopiert"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Aufräumen
|
||||||
|
rm -f ./data/prod.db.tmp ./data/prod.db.tmp.backup
|
||||||
|
|
||||||
|
echo "🔄 Starte Container neu..."
|
||||||
|
docker compose restart hoerdle
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "✅ Fertig! Prüfe die Logs:"
|
||||||
|
echo " docker logs hoerdle --tail=50"
|
||||||
|
|
||||||
51
scripts/fix-network.sh
Executable file
51
scripts/fix-network.sh
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script zum Reparieren des Docker-Netzwerks hoerdle_default
|
||||||
|
# Dieses Script behebt die Warnung über falsche Netzwerk-Labels
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 Repariere Docker-Netzwerk hoerdle_default..."
|
||||||
|
|
||||||
|
# Prüfe, ob Container laufen
|
||||||
|
RUNNING_CONTAINERS=$(docker ps --filter "network=hoerdle_default" --format "{{.Names}}" | wc -l)
|
||||||
|
|
||||||
|
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
|
||||||
|
echo "⚠️ Warnung: Es laufen noch Container, die das Netzwerk nutzen."
|
||||||
|
echo "📋 Container, die betroffen sind:"
|
||||||
|
docker ps --filter "network=hoerdle_default" --format " - {{.Names}}"
|
||||||
|
echo ""
|
||||||
|
echo "Möchtest du fortfahren? Die Container müssen neu gestartet werden. (j/n)"
|
||||||
|
read -r response
|
||||||
|
if [ "$response" != "j" ] && [ "$response" != "J" ]; then
|
||||||
|
echo "❌ Abgebrochen."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🛑 Stoppe Container..."
|
||||||
|
docker compose down || true
|
||||||
|
if [ -f "docker-compose.caddy.yml" ]; then
|
||||||
|
docker compose -f docker-compose.caddy.yml down || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prüfe, ob Netzwerk existiert
|
||||||
|
if docker network ls | grep -q "hoerdle_default"; then
|
||||||
|
echo "🗑️ Lösche altes Netzwerk..."
|
||||||
|
docker network rm hoerdle_default || {
|
||||||
|
echo "❌ Netzwerk konnte nicht gelöscht werden. Möglicherweise sind noch Container verbunden."
|
||||||
|
echo " Versuche, alle Container zu trennen..."
|
||||||
|
docker network disconnect hoerdle_default $(docker ps -q --filter "network=hoerdle_default") 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
docker network rm hoerdle_default || {
|
||||||
|
echo "❌ Netzwerk konnte immer noch nicht gelöscht werden."
|
||||||
|
echo " Bitte manuell prüfen: docker network inspect hoerdle_default"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✨ Netzwerk erfolgreich gelöscht."
|
||||||
|
echo "📝 Das Netzwerk wird beim nächsten 'docker compose up' automatisch neu erstellt."
|
||||||
|
echo ""
|
||||||
|
echo "✅ Fertig! Du kannst jetzt 'docker compose up -d' ausführen."
|
||||||
|
|
||||||
47
scripts/quick-fix-db.sh
Executable file
47
scripts/quick-fix-db.sh
Executable file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Quick-Fix für Datenbank-Berechtigungen
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 Quick-Fix für Datenbank-Berechtigungen..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
|
||||||
|
# Setze Berechtigungen
|
||||||
|
echo "1️⃣ Setze Berechtigungen..."
|
||||||
|
chmod 775 ./data
|
||||||
|
chmod 664 ./data/prod.db 2>/dev/null || echo " ⚠️ Datenbankdatei nicht gefunden"
|
||||||
|
|
||||||
|
# Setze Besitzer auf root (Container läuft als root)
|
||||||
|
echo "2️⃣ Setze Besitzer auf root..."
|
||||||
|
sudo chown -R root:root ./data
|
||||||
|
|
||||||
|
# Zeige aktuelle Berechtigungen
|
||||||
|
echo ""
|
||||||
|
echo "✅ Berechtigungen gesetzt!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Aktuelle Berechtigungen:"
|
||||||
|
ls -ld ./data
|
||||||
|
ls -lh ./data/prod.db 2>/dev/null || echo " (Datei nicht gefunden)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Teste im Container
|
||||||
|
echo "3️⃣ Teste Zugriff im Container..."
|
||||||
|
if docker ps | grep -q hoerdle; then
|
||||||
|
echo " Container läuft, teste Zugriff..."
|
||||||
|
docker exec hoerdle sh -c "test -r /app/data/prod.db && echo '✅ Lesbar' || echo '❌ Nicht lesbar'"
|
||||||
|
docker exec hoerdle sh -c "test -w /app/data/prod.db && echo '✅ Schreibbar' || echo '❌ Nicht schreibbar'"
|
||||||
|
docker exec hoerdle sh -c "test -w /app/data && echo '✅ Verzeichnis schreibbar' || echo '❌ Verzeichnis nicht schreibbar'"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Container läuft nicht"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔄 Starte Container neu..."
|
||||||
|
docker compose restart hoerdle 2>/dev/null || echo " ⚠️ Konnte Container nicht neustarten (vielleicht läuft docker compose nicht?)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Fertig! Prüfe jetzt die Logs:"
|
||||||
|
echo " docker logs hoerdle --tail=50"
|
||||||
|
|
||||||
@@ -43,10 +43,22 @@ async function restoreSongs() {
|
|||||||
// Simple normalization
|
// Simple normalization
|
||||||
const normalizedGenre = genreName.trim();
|
const normalizedGenre = genreName.trim();
|
||||||
|
|
||||||
// Upsert genre (we can't use upsert easily with connect, so find or create first)
|
// Find genre by checking all genres (name is now JSON)
|
||||||
let genre = await prisma.genre.findUnique({ where: { name: normalizedGenre } });
|
const allGenres = await prisma.genre.findMany();
|
||||||
|
let genre = allGenres.find(g => {
|
||||||
|
const name = g.name as any;
|
||||||
|
return (typeof name === 'string' && name === normalizedGenre) ||
|
||||||
|
(typeof name === 'object' && (name.de === normalizedGenre || name.en === normalizedGenre));
|
||||||
|
});
|
||||||
|
|
||||||
if (!genre) {
|
if (!genre) {
|
||||||
genre = await prisma.genre.create({ data: { name: normalizedGenre } });
|
// Create with JSON structure
|
||||||
|
genre = await prisma.genre.create({
|
||||||
|
data: {
|
||||||
|
name: { de: normalizedGenre, en: normalizedGenre },
|
||||||
|
active: true
|
||||||
|
}
|
||||||
|
});
|
||||||
console.log(`Created genre: ${normalizedGenre}`);
|
console.log(`Created genre: ${normalizedGenre}`);
|
||||||
}
|
}
|
||||||
genreConnect.push({ id: genre.id });
|
genreConnect.push({ id: genre.id });
|
||||||
|
|||||||
31
scripts/verify-i18n-migration.ts
Normal file
31
scripts/verify-i18n-migration.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Verifying i18n migration...');
|
||||||
|
|
||||||
|
const genre = await prisma.genre.findFirst();
|
||||||
|
if (!genre) {
|
||||||
|
console.log('No genres found to verify.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('First genre:', JSON.stringify(genre, null, 2));
|
||||||
|
|
||||||
|
if (typeof genre.name === 'object' && genre.name !== null && 'de' in (genre.name as any)) {
|
||||||
|
console.log('SUCCESS: Genre name is a JSON object with "de" key.');
|
||||||
|
} else {
|
||||||
|
console.error('FAILURE: Genre name is NOT in expected format:', genre.name);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user