Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28d14ff099 | ||
|
|
b1493b44bf | ||
|
|
b8a803b76e | ||
|
|
e2bdf0fc88 | ||
|
|
2cb9af8d2b | ||
|
|
d6ad01b00e | ||
|
|
693817b18c | ||
|
|
41336e3af3 | ||
|
|
d7ec691469 | ||
|
|
5e1700712e | ||
|
|
f691384a34 | ||
|
|
f0d75c591a | ||
|
|
1f34d5813e | ||
|
|
33f8080aa8 | ||
|
|
8a102afc0e | ||
|
|
38148ace8d | ||
|
|
49e98ade3c | ||
|
|
397839cc1f | ||
|
|
3fe805129b | ||
|
|
bf9a49a9ac | ||
|
|
9b89cbf8ed | ||
|
|
7f33e98fb5 | ||
|
|
72f8b99092 | ||
|
|
e60daa511b | ||
|
|
19706abacb | ||
|
|
170e7b5402 | ||
|
|
ade1043c3c | ||
|
|
d69af49e24 | ||
|
|
63687524e7 | ||
|
|
0246cb58ee | ||
|
|
d76aa9f4e9 | ||
|
|
28afaf598b | ||
|
|
8239753911 | ||
|
|
0bfcf0737e | ||
|
|
5409196008 | ||
|
|
a59f6f747e | ||
|
|
dc763c88a3 | ||
|
|
1613bf0dda | ||
|
|
b872e87b50 | ||
|
|
87c1ee63ec | ||
|
|
8c57e938e8 | ||
|
|
9eb07ee8d5 | ||
|
|
3eb6c7f5cf | ||
|
|
2846afb6f7 | ||
|
|
27fa689b18 | ||
|
|
61846a6982 | ||
|
|
bba6b9ef31 | ||
|
|
a8867ac42e | ||
|
|
9006b208af | ||
|
|
20c8ad7eaf | ||
|
|
03129a5611 | ||
|
|
fd8f4adcc0 | ||
|
|
23997ccc3a | ||
|
|
85bdbf795c | ||
|
|
ac0bb02ba0 | ||
|
|
63269c2600 | ||
|
|
17a39d677d | ||
|
|
1ff0787e4e | ||
|
|
ed5f02bdec | ||
|
|
e3a09864a6 | ||
|
|
107739ade9 | ||
|
|
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 | ||
|
|
fea8384e60 | ||
|
|
de8813da3e | ||
|
|
0877842107 | ||
|
|
a5cbbffc20 | ||
|
|
ffb7be602f | ||
|
|
1d62aca2fb | ||
|
|
9bf7e72a6c | ||
|
|
f8b5dcf300 | ||
|
|
072158f4ed | ||
|
|
898d2f5959 | ||
|
|
a7aec80f39 | ||
|
|
0e313db2e3 | ||
|
|
3e647cd44b | ||
|
|
54af256e91 | ||
|
|
ce413cf6bc | ||
|
|
5102ca86cb | ||
|
|
eb3d2c86d7 |
67
.dockerignore
Normal file
67
.dockerignore
Normal file
@@ -0,0 +1,67 @@
|
||||
# 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
|
||||
/docs
|
||||
*.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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -50,3 +51,6 @@ next-env.d.ts
|
||||
/data
|
||||
.release-years-migrated
|
||||
.covers-migrated
|
||||
docker-compose.yml
|
||||
scripts/scrape-bahn-expert-statements.js
|
||||
docs/bahn-expert-statements.txt
|
||||
|
||||
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
|
||||
}
|
||||
47
Dockerfile
47
Dockerfile
@@ -13,18 +13,62 @@ RUN npm ci
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Accept version as build argument (optional)
|
||||
ARG APP_VERSION=""
|
||||
|
||||
# Install git to extract version information
|
||||
RUN apk add --no-cache git
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Extract version: use build arg if provided, otherwise get from git, fallback to package.json
|
||||
RUN if [ -n "$APP_VERSION" ]; then \
|
||||
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||
else \
|
||||
(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 && \
|
||||
echo "Building version: $(cat /tmp/version.txt)"
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Suppress baseline-browser-mapping warning about old data (informational only)
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Generate Prisma Client
|
||||
ENV DATABASE_URL="file:./dev.db"
|
||||
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_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_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
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
@@ -53,6 +97,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
# Create uploads directory and set permissions
|
||||
RUN mkdir -p public/uploads/covers && chown -R nextjs:nodejs public/uploads
|
||||
|
||||
# Copy version file from builder
|
||||
COPY --from=builder /tmp/version.txt /app/version.txt
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
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
|
||||
|
||||
51
README.md
51
README.md
@@ -4,6 +4,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
|
||||
## 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.
|
||||
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
||||
- **Admin Dashboard:**
|
||||
@@ -41,7 +42,34 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Live-Vorschau beim Hovern über die Waveform.
|
||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Manuelle Speicherung mit visueller Bestätigung.
|
||||
- **News & Announcements:**
|
||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||
- Verwaltung über das Admin-Dashboard.
|
||||
|
||||
## Internationalisierung (i18n)
|
||||
|
||||
Hördle unterstützt vollständige Mehrsprachigkeit für Deutsch und Englisch.
|
||||
|
||||
👉 **[Vollständige i18n-Dokumentation](docs/I18N.md)**
|
||||
|
||||
**Schnellstart:**
|
||||
- Deutsche Version: `http://localhost:3000/de`
|
||||
- Englische Version: `http://localhost:3000/en`
|
||||
- Root (`/`) leitet automatisch zur Standardsprache (Englisch) 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)](docs/WHITE_LABEL.md)**
|
||||
|
||||
Die Konfiguration erfolgt einfach über Umgebungsvariablen und CSS-Variablen.
|
||||
|
||||
## Spielregeln & Punktesystem
|
||||
|
||||
@@ -49,13 +77,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
|
||||
- **Start-Punktestand:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Überspringen (Skip):** -5 Punkte
|
||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
||||
|
||||
**Hinweis:** Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die automatische Verlängerung des Audio-Snippets (unlockSteps) abgezogen, um die Verwendung dieses Hilfsmittels zu reflektieren.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
@@ -87,12 +117,14 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000`.
|
||||
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/en` um).
|
||||
|
||||
## Deployment mit Docker
|
||||
|
||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||
|
||||
👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
|
||||
|
||||
1. **Vorbereitung:**
|
||||
Kopiere die Beispiel-Konfiguration:
|
||||
```bash
|
||||
@@ -121,7 +153,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.
|
||||
|
||||
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.)
|
||||
|
||||
5. **Special Curation & Scheduling verwenden:**
|
||||
@@ -192,12 +224,12 @@ Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die A
|
||||
|
||||
### Genre-spezifische Einbindung
|
||||
|
||||
Einzelne Genres können direkt eingebunden werden:
|
||||
Einzelne Genres können direkt eingebunden werden (mit Locale-Präfix):
|
||||
|
||||
```html
|
||||
<!-- Rock Genre -->
|
||||
<!-- Rock Genre (Deutsch) -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/Rock"
|
||||
src="https://hoerdle.elpatron.me/de/Rock"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
@@ -205,9 +237,9 @@ Einzelne Genres können direkt eingebunden werden:
|
||||
title="Hördle Rock Quiz">
|
||||
</iframe>
|
||||
|
||||
<!-- Pop Genre -->
|
||||
<!-- Pop Genre (Englisch) -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/Pop"
|
||||
src="https://hoerdle.elpatron.me/en/Pop"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
@@ -221,8 +253,9 @@ Einzelne Genres können direkt eingebunden werden:
|
||||
Auch thematische Specials können direkt eingebettet werden:
|
||||
|
||||
```html
|
||||
<!-- Weihnachtslieder (Deutsch) -->
|
||||
<iframe
|
||||
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
|
||||
src="https://hoerdle.elpatron.me/de/special/Weihnachtslieder"
|
||||
width="100%"
|
||||
height="800"
|
||||
frameborder="0"
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import Game from '@/components/Game';
|
||||
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>
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
app/[locale]/[genre]/page.tsx
Normal file
165
app/[locale]/[genre]/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
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';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string; genre: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, genre } = await params;
|
||||
const decodedGenre = decodeURIComponent(genre);
|
||||
|
||||
// Fetch genre to get localized name
|
||||
const allGenres = await prisma.genre.findMany();
|
||||
const currentGenre = allGenres.find(g => getLocalizedValue(g.name, locale) === decodedGenre);
|
||||
|
||||
if (!currentGenre || !currentGenre.active) {
|
||||
return await generateBaseMetadata(locale, genre);
|
||||
}
|
||||
|
||||
const genreName = getLocalizedValue(currentGenre.name, locale);
|
||||
const genreSubtitle = getLocalizedValue(currentGenre.subtitle, locale);
|
||||
|
||||
const title = locale === 'de'
|
||||
? `${genreName} - Hördle`
|
||||
: `${genreName} - Hördle`;
|
||||
|
||||
const description = genreSubtitle || (locale === 'de'
|
||||
? `Spiele Hördle im Genre ${genreName} und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play Hördle in the ${genreName} genre and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, genre, title, description);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||
|
||||
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} requiredDailyKeys={requiredDailyKeys} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
324
app/[locale]/about/page.tsx
Normal file
324
app/[locale]/about/page.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/lib/navigation";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
interface AboutPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "About" });
|
||||
|
||||
const title = t("title");
|
||||
const description = t("intro");
|
||||
|
||||
return await generateBaseMetadata(locale, "about", title, description);
|
||||
}
|
||||
|
||||
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>
|
||||
</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.5rem",
|
||||
fontSize: "0.9rem",
|
||||
color: "#6b7280",
|
||||
}}
|
||||
>
|
||||
{t("costsSheetPrivacyNote")}
|
||||
</p>
|
||||
<p style={{ marginBottom: "0.75rem" }}>
|
||||
{t.rich("costsDonationNote", {
|
||||
link: (chunks) => (
|
||||
<a
|
||||
href="https://politicalbeauty.de/ueber-das-ZPS.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</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>
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "0.5rem",
|
||||
backgroundColor: "#f9fafb",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: "600",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{t("supportCuratorTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t("supportCuratorText")}
|
||||
</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("supportReportBugTitle")}
|
||||
</h3>
|
||||
<p style={{ marginBottom: 0 }}>
|
||||
{t.rich("supportReportBugText", {
|
||||
email: (chunks) => (
|
||||
<a
|
||||
href="mailto:admin@hoerdle.de"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{chunks}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
2187
app/[locale]/admin/page.tsx
Normal file
2187
app/[locale]/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
11
app/[locale]/curator/page.tsx
Normal file
11
app/[locale]/curator/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import CuratorPageInner from '../../curator/page';
|
||||
|
||||
export default function CuratorPage() {
|
||||
// Wrapper für die lokalisierte Route /[locale]/curator
|
||||
// Hinweis: Pfad '../../curator/page' zeigt von 'app/[locale]/curator' korrekt auf 'app/curator/page'.
|
||||
return <CuratorPageInner />;
|
||||
}
|
||||
|
||||
|
||||
98
app/[locale]/layout.tsx
Normal file
98
app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 { headers } from 'next/headers';
|
||||
|
||||
import { config } from "@/lib/config";
|
||||
import { generateBaseMetadata } from "@/lib/metadata";
|
||||
import InstallPrompt from "@/components/InstallPrompt";
|
||||
import AppFooter from "@/components/AppFooter";
|
||||
import PoliticalStatementBanner from "@/components/PoliticalStatementBanner";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return await generateBaseMetadata(locale);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Get current domain from request headers for dynamic Plausible tracking
|
||||
// This automatically tracks the correct domain (hoerdle.de or hördle.de)
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||
|
||||
// Automatically detect which domain to track in Plausible based on the request
|
||||
let plausibleDomain = 'hoerdle.de'; // Default fallback
|
||||
|
||||
if (host) {
|
||||
// Extract domain from host (remove port if present)
|
||||
const domain = host.split(':')[0].toLowerCase();
|
||||
|
||||
// Map domains: automatically track the current domain
|
||||
if (domain === 'hoerdle.de') {
|
||||
plausibleDomain = 'hoerdle.de';
|
||||
} else if (domain === 'hördle.de' || domain === 'xn--hrdle-jua.de') {
|
||||
plausibleDomain = 'hördle.de';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<Script
|
||||
defer
|
||||
data-domain={plausibleDomain}
|
||||
src={config.plausibleScriptSrc}
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<AppFooter />
|
||||
<PoliticalStatementBanner />
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
159
app/[locale]/page.tsx
Normal file
159
app/[locale]/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations('Home');
|
||||
|
||||
// Get localized title and description
|
||||
const title = locale === 'de'
|
||||
? 'Hördle - Tägliches Musik-Erraten'
|
||||
: 'Hördle - Daily Music Guessing Game';
|
||||
|
||||
const description = locale === 'de'
|
||||
? 'Spiele Hördle und errate Songs aus kurzen Audio-Clips! Täglich neue Rätsel aus verschiedenen Genres. Inspiriert von Wordle, aber für Musikfans.'
|
||||
: 'Play Hördle and guess songs from short audio clips! Daily new puzzles from various genres. Inspired by Wordle, but for music lovers.';
|
||||
|
||||
return await generateBaseMetadata(locale, '', title, description);
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ 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;
|
||||
});
|
||||
|
||||
// Required daily keys: global + all active genres (by localized name, as used in gameState storage)
|
||||
const requiredDailyKeys = ['global', ...genres.map(g => getLocalizedValue(g.name, locale))];
|
||||
|
||||
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} requiredDailyKeys={requiredDailyKeys} />
|
||||
<OnboardingTour />
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
app/[locale]/special/[name]/page.tsx
Normal file
151
app/[locale]/special/[name]/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
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';
|
||||
import { generateBaseMetadata } from '@/lib/metadata';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string; name: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, name } = await params;
|
||||
const decodedName = decodeURIComponent(name);
|
||||
|
||||
// Fetch special to get localized name
|
||||
const allSpecials = await prisma.special.findMany();
|
||||
const currentSpecial = allSpecials.find(s => getLocalizedValue(s.name, locale) === decodedName);
|
||||
|
||||
if (!currentSpecial) {
|
||||
return await generateBaseMetadata(locale, `special/${name}`);
|
||||
}
|
||||
|
||||
const specialName = getLocalizedValue(currentSpecial.name, locale);
|
||||
const specialSubtitle = getLocalizedValue(currentSpecial.subtitle, locale);
|
||||
|
||||
const title = `★ ${specialName} - Hördle`;
|
||||
|
||||
const description = specialSubtitle || (locale === 'de'
|
||||
? `Spiele das Hördle-Special "${specialName}" und errate Songs aus kurzen Audio-Clips!`
|
||||
: `Play the Hördle special "${specialName}" and guess songs from short audio clips!`);
|
||||
|
||||
return await generateBaseMetadata(locale, `special/${name}`, title, description);
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1163
app/admin/page.tsx
1163
app/admin/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -98,14 +98,14 @@ export async function DELETE(request: Request) {
|
||||
where: { id: puzzle.specialId }
|
||||
});
|
||||
if (special) {
|
||||
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
|
||||
newPuzzle = await getOrCreateSpecialPuzzle(special);
|
||||
}
|
||||
} else if (puzzle.genreId) {
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { id: puzzle.genreId }
|
||||
});
|
||||
if (genre) {
|
||||
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
|
||||
newPuzzle = await getOrCreateDailyPuzzle(genre);
|
||||
}
|
||||
} else {
|
||||
newPuzzle = await getOrCreateDailyPuzzle(null);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
export async function GET(
|
||||
@@ -30,24 +31,106 @@ export async function GET(
|
||||
return new NextResponse('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const stats = await stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const range = request.headers.get('range');
|
||||
|
||||
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 {
|
||||
await stat(filePath);
|
||||
} catch {
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
controller.enqueue(chunk);
|
||||
} catch (e) {
|
||||
isClosed = true;
|
||||
stream.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Read file
|
||||
const fileBuffer = await readFile(filePath);
|
||||
stream.on('end', () => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
controller.close();
|
||||
});
|
||||
|
||||
// Return with proper headers
|
||||
return new NextResponse(fileBuffer, {
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error serving audio file:', error);
|
||||
return new NextResponse('Internal Server Error', { status: 500 });
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -83,7 +84,8 @@ export async function POST(request: Request) {
|
||||
// Process each song in this batch
|
||||
for (const song of uncategorizedSongs) {
|
||||
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.
|
||||
|
||||
@@ -140,7 +142,7 @@ Your response:`;
|
||||
|
||||
// Filter to only valid genres and get their IDs
|
||||
const genreIds = allGenres
|
||||
.filter(g => suggestedGenreNames.includes(g.name))
|
||||
.filter(g => suggestedGenreNames.includes(getLocalizedValue(g.name, 'de')))
|
||||
.map(g => g.id)
|
||||
.slice(0, 3); // Max 3 genres
|
||||
|
||||
@@ -160,7 +162,7 @@ Your response:`;
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
assignedGenres: suggestedGenreNames.filter(name =>
|
||||
allGenres.some(g => g.name === name)
|
||||
allGenres.some(g => getLocalizedValue(g.name, 'de') === name)
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
42
app/api/curator/login/route.ts
Normal file
42
app/api/curator/login/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { username, password } = await request.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const curator = await prisma.curator.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
|
||||
if (!curator) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, curator.passwordHash);
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
curator: {
|
||||
id: curator.id,
|
||||
username: curator.username,
|
||||
isGlobalCurator: curator.isGlobalCurator,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Curator login error:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
app/api/curator/me/route.ts
Normal file
38
app/api/curator/me/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireStaffAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
if (context.role !== 'curator') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only curators can access this endpoint' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const [genres, specials] = await Promise.all([
|
||||
prisma.curatorGenre.findMany({
|
||||
where: { curatorId: context.curator.id },
|
||||
select: { genreId: true },
|
||||
}),
|
||||
prisma.curatorSpecial.findMany({
|
||||
where: { curatorId: context.curator.id },
|
||||
select: { specialId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
id: context.curator.id,
|
||||
username: context.curator.username,
|
||||
isGlobalCurator: context.curator.isGlobalCurator,
|
||||
genreIds: genres.map(g => g.genreId),
|
||||
specialIds: specials.map(s => s.specialId),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
200
app/api/curators/route.ts
Normal file
200
app/api/curators/route.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
// Only admin may list and manage curators
|
||||
const authError = await requireAdminAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const curators = await prisma.curator.findMany({
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
orderBy: { username: 'asc' },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
curators.map(c => ({
|
||||
id: c.id,
|
||||
username: c.username,
|
||||
isGlobalCurator: c.isGlobalCurator,
|
||||
genreIds: c.genres.map(g => g.genreId),
|
||||
specialIds: c.specials.map(s => s.specialId),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdminAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { username, password, isGlobalCurator = false, genreIds = [], specialIds = [] } = await request.json();
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
try {
|
||||
const curator = await prisma.curator.create({
|
||||
data: {
|
||||
username,
|
||||
passwordHash,
|
||||
isGlobalCurator: Boolean(isGlobalCurator),
|
||||
genres: {
|
||||
create: (genreIds as number[]).map(id => ({ genreId: id })),
|
||||
},
|
||||
specials: {
|
||||
create: (specialIds as number[]).map(id => ({ specialId: id })),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: curator.id,
|
||||
username: curator.username,
|
||||
isGlobalCurator: curator.isGlobalCurator,
|
||||
genreIds: curator.genres.map(g => g.genreId),
|
||||
specialIds: curator.specials.map(s => s.specialId),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating curator:', error);
|
||||
|
||||
// Handle unique username constraint violation explicitly
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return NextResponse.json(
|
||||
{ error: 'A curator with this username already exists.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const authError = await requireAdminAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id, username, password, isGlobalCurator, genreIds, specialIds } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
if (username !== undefined) data.username = username;
|
||||
if (isGlobalCurator !== undefined) data.isGlobalCurator = Boolean(isGlobalCurator);
|
||||
if (password) {
|
||||
data.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
const curator = await tx.curator.update({
|
||||
where: { id: Number(id) },
|
||||
data,
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(genreIds)) {
|
||||
await tx.curatorGenre.deleteMany({
|
||||
where: { curatorId: curator.id },
|
||||
});
|
||||
if (genreIds.length > 0) {
|
||||
await tx.curatorGenre.createMany({
|
||||
data: (genreIds as number[]).map(gid => ({
|
||||
curatorId: curator.id,
|
||||
genreId: gid,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(specialIds)) {
|
||||
await tx.curatorSpecial.deleteMany({
|
||||
where: { curatorId: curator.id },
|
||||
});
|
||||
if (specialIds.length > 0) {
|
||||
await tx.curatorSpecial.createMany({
|
||||
data: (specialIds as number[]).map(sid => ({
|
||||
curatorId: curator.id,
|
||||
specialId: sid,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const finalCurator = await tx.curator.findUnique({
|
||||
where: { id: curator.id },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!finalCurator) {
|
||||
throw new Error('Curator not found after update');
|
||||
}
|
||||
|
||||
return finalCurator;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: updated.id,
|
||||
username: updated.username,
|
||||
isGlobalCurator: updated.isGlobalCurator,
|
||||
genreIds: updated.genres.map(g => g.genreId),
|
||||
specialIds: updated.specials.map(s => s.specialId),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating curator:', error);
|
||||
|
||||
// Handle unique username constraint violation explicitly for updates
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
return NextResponse.json(
|
||||
{ error: 'A curator with this username already exists.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireAdminAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id } = await request.json();
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.curator.delete({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting curator:', error);
|
||||
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
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) {
|
||||
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale');
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
// orderBy: { name: 'asc' }, // Cannot sort by JSON field easily in SQLite
|
||||
include: {
|
||||
_count: {
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Error fetching genres:', error);
|
||||
@@ -29,14 +45,18 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const { name, subtitle, active } = await request.json();
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
if (!name) {
|
||||
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({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
name: nameData,
|
||||
subtitle: subtitleData,
|
||||
active: active !== undefined ? active : true
|
||||
},
|
||||
});
|
||||
@@ -83,13 +103,14 @@ export async function PUT(request: Request) {
|
||||
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({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(name && { name: name.trim() }),
|
||||
subtitle: subtitle ? subtitle.trim() : null,
|
||||
...(active !== undefined && { active })
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
165
app/api/news/route.ts
Normal file
165
app/api/news/route.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// GET /api/news - Public endpoint to fetch news
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const featuredOnly = searchParams.get('featured') === 'true';
|
||||
const locale = searchParams.get('locale');
|
||||
|
||||
const where = featuredOnly ? { featured: true } : {};
|
||||
|
||||
const news = await prisma.news.findMany({
|
||||
where,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: limit,
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Error fetching news:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/news - Create news (requires auth)
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { title, content, author, featured, specialId } = body;
|
||||
|
||||
if (!title || !content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title and content are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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({
|
||||
data: {
|
||||
title: titleData,
|
||||
content: contentData,
|
||||
author: author || null,
|
||||
featured: featured || false,
|
||||
specialId: specialId || null
|
||||
},
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(news, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating news:', error);
|
||||
return NextResponse.json({ error: 'Failed to create news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/news - Update news (requires auth)
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, title, content, author, featured, specialId } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (title !== undefined) updateData.title = typeof title === 'string' ? { de: title, en: title } : title;
|
||||
if (content !== undefined) updateData.content = typeof content === 'string' ? { de: content, en: content } : content;
|
||||
if (author !== undefined) updateData.author = author || null;
|
||||
if (featured !== undefined) updateData.featured = featured;
|
||||
if (specialId !== undefined) updateData.specialId = specialId || null;
|
||||
|
||||
const news = await prisma.news.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
special: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(news);
|
||||
} catch (error) {
|
||||
console.error('Error updating news:', error);
|
||||
return NextResponse.json({ error: 'Failed to update news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/news - Delete news (requires auth)
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'News ID is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.news.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting news:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete news' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
78
app/api/og-image/route.ts
Normal file
78
app/api/og-image/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { config } from '@/lib/config';
|
||||
import { getBaseUrl } from '@/lib/seo';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Generate Open Graph image as SVG with correct aspect ratio (1.91:1 = 1200x630)
|
||||
* This prevents cropping on Facebook and Twitter
|
||||
*/
|
||||
export async function GET() {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const appName = config.appName;
|
||||
const bgColor = config.colors.backgroundColor || '#ffffff';
|
||||
const primaryColor = config.colors.themeColor || '#000000';
|
||||
|
||||
// SVG with correct Open Graph dimensions: 1200x630 (1.91:1 ratio)
|
||||
// Safe area: 150px padding on all sides to prevent cropping
|
||||
// This ensures content is never cut off on Facebook/Twitter
|
||||
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background -->
|
||||
<rect width="1200" height="630" fill="${bgColor}"/>
|
||||
|
||||
<!-- Gradient definition -->
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#06b6d4;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Content container - centered with safe padding (150px on all sides) -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Main graphic area (centered horizontally) -->
|
||||
<g transform="translate(300, 0)">
|
||||
<!-- Musical note (left side, within safe area) -->
|
||||
<g fill="url(#gradient)" opacity="0.9">
|
||||
<!-- Note head -->
|
||||
<ellipse cx="0" cy="40" rx="40" ry="28"/>
|
||||
<!-- Note stem -->
|
||||
<rect x="30" y="-60" width="16" height="100" rx="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Waveform (center-right, within safe area) -->
|
||||
<g transform="translate(70, 15)" fill="none" stroke="url(#gradient)" stroke-width="8" stroke-linecap="round" opacity="0.8">
|
||||
<path d="M 0 25 Q 20 -15 40 25 T 80 25"/>
|
||||
<path d="M 0 40 Q 20 0 40 40 T 80 40"/>
|
||||
<path d="M 0 55 Q 20 15 40 55 T 80 55"/>
|
||||
</g>
|
||||
|
||||
<!-- Vertical bar (right side, within safe area) -->
|
||||
<rect x="170" y="0" width="10" height="120" fill="url(#gradient)" opacity="0.7" rx="5"/>
|
||||
</g>
|
||||
|
||||
<!-- App name (centered, within safe vertical area) -->
|
||||
<text x="450" y="180" font-family="system-ui, -apple-system, sans-serif" font-size="56" font-weight="bold" fill="${primaryColor}" text-anchor="middle" letter-spacing="-0.5">
|
||||
${appName}
|
||||
</text>
|
||||
|
||||
<!-- Domain/subtitle (centered, within safe vertical area) -->
|
||||
<text x="450" y="220" font-family="system-ui, -apple-system, sans-serif" font-size="28" fill="#666666" text-anchor="middle">
|
||||
${config.domain}
|
||||
</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
return new NextResponse(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
114
app/api/player-id/suggest/route.ts
Normal file
114
app/api/player-id/suggest/route.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* POST /api/player-id/suggest
|
||||
*
|
||||
* Tries to find a base player ID based on recently updated states for a genre and device.
|
||||
* This helps synchronize player IDs across different domains (hoerdle.de and hördle.de)
|
||||
* on the same device.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - deviceId: Device identifier (UUID)
|
||||
*
|
||||
* Returns:
|
||||
* - basePlayerId: Suggested base player ID (UUID) if found, null otherwise
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { genreKey, deviceId } = body;
|
||||
|
||||
if (!genreKey || typeof genreKey !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing or invalid genreKey' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the most recently updated player state for this genre
|
||||
// Look for states updated in the last 48 hours
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setHours(cutoffDate.getHours() - 48);
|
||||
|
||||
// If deviceId is provided, search for states with matching device ID
|
||||
// Format: {basePlayerId}:{deviceId}
|
||||
if (deviceId && typeof deviceId === 'string') {
|
||||
// Search for states with the same device ID
|
||||
const recentStates = await prisma.playerState.findMany({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
identifier: {
|
||||
endsWith: `:${deviceId}`,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (recentStates.length > 0) {
|
||||
const recentState = recentStates[0];
|
||||
// Extract base player ID from full identifier
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Find any recent state for this genre (legacy support)
|
||||
const recentState = await prisma.playerState.findFirst({
|
||||
where: {
|
||||
genreKey: genreKey,
|
||||
lastPlayed: {
|
||||
gte: cutoffDate,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastPlayed: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (recentState) {
|
||||
// Extract base player ID if format is basePlayerId:deviceId
|
||||
const colonIndex = recentState.identifier.indexOf(':');
|
||||
if (colonIndex !== -1) {
|
||||
const basePlayerId = recentState.identifier.substring(0, colonIndex);
|
||||
return NextResponse.json({
|
||||
basePlayerId: basePlayerId,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
} else {
|
||||
// Legacy format: return as-is
|
||||
return NextResponse.json({
|
||||
basePlayerId: recentState.identifier,
|
||||
lastPlayed: recentState.lastPlayed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No recent state found
|
||||
return NextResponse.json({
|
||||
basePlayerId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-id/suggest] Error finding player ID:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
192
app/api/player-state/route.ts
Normal file
192
app/api/player-state/route.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import type { GameState, Statistics } from '@/lib/gameState';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Validate UUID format (basic check)
|
||||
* Supports both legacy format (single UUID) and new format (basePlayerId:deviceId)
|
||||
*/
|
||||
function isValidPlayerId(playerId: string): boolean {
|
||||
// Legacy format: single UUID
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// New format: basePlayerId:deviceId (two UUIDs separated by colon)
|
||||
const combinedRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
return uuidRegex.test(playerId) || combinedRegex.test(playerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base player ID from full player ID
|
||||
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||
* Legacy: {uuid} -> {uuid}
|
||||
*/
|
||||
function extractBasePlayerId(fullPlayerId: string): string {
|
||||
const colonIndex = fullPlayerId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// Legacy format (no device ID) - return as is
|
||||
return fullPlayerId;
|
||||
}
|
||||
return fullPlayerId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/player-state
|
||||
*
|
||||
* Loads player state for a given identifier and genre/special.
|
||||
*
|
||||
* Query parameters:
|
||||
* - genre: Genre name (e.g., "Rock")
|
||||
* - special: Special name (e.g., "00725")
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const genreName = searchParams.get('genre');
|
||||
const specialName = searchParams.get('special');
|
||||
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine genre key
|
||||
let genreKey: string;
|
||||
if (specialName) {
|
||||
genreKey = `special:${specialName}`;
|
||||
} else if (genreName) {
|
||||
genreKey = genreName;
|
||||
} else {
|
||||
genreKey = 'global';
|
||||
}
|
||||
|
||||
// Load player state from database
|
||||
const playerState = await prisma.playerState.findUnique({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!playerState) {
|
||||
return NextResponse.json(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse JSON strings
|
||||
let gameState: GameState;
|
||||
let statistics: Statistics;
|
||||
|
||||
try {
|
||||
gameState = JSON.parse(playerState.gameState) as GameState;
|
||||
statistics = JSON.parse(playerState.statistics) as Statistics;
|
||||
} catch (parseError) {
|
||||
console.error('[player-state] Failed to parse stored state:', parseError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid stored state format' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
gameState,
|
||||
statistics,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error loading player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/player-state
|
||||
*
|
||||
* Saves player state for a given identifier and genre/special.
|
||||
*
|
||||
* Request body:
|
||||
* - genreKey: Genre key (e.g., "global", "Rock", "special:00725")
|
||||
* - gameState: GameState object
|
||||
* - statistics: Statistics object
|
||||
*
|
||||
* Headers:
|
||||
* - X-Player-Id: Player identifier (UUID)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// Get player identifier from header
|
||||
const playerId = request.headers.get('X-Player-Id');
|
||||
if (!playerId || !isValidPlayerId(playerId)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or missing player identifier' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { genreKey, gameState, statistics } = body;
|
||||
|
||||
if (!genreKey || !gameState || !statistics) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: genreKey, gameState, statistics' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate genre key format
|
||||
if (typeof genreKey !== 'string' || genreKey.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid genreKey format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize to JSON strings
|
||||
const gameStateJson = JSON.stringify(gameState);
|
||||
const statisticsJson = JSON.stringify(statistics);
|
||||
|
||||
// Upsert player state (update if exists, create if not)
|
||||
await prisma.playerState.upsert({
|
||||
where: {
|
||||
identifier_genreKey: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
lastPlayed: new Date(),
|
||||
},
|
||||
create: {
|
||||
identifier: playerId,
|
||||
genreKey: genreKey,
|
||||
gameState: gameStateJson,
|
||||
statistics: statisticsJson,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[player-state] Error saving player state:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
113
app/api/political-statements/route.ts
Normal file
113
app/api/political-statements/route.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import {
|
||||
getRandomActiveStatement,
|
||||
getAllStatements,
|
||||
createStatement,
|
||||
updateStatement,
|
||||
deleteStatement,
|
||||
} from '@/lib/politicalStatements';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
const admin = searchParams.get('admin') === 'true';
|
||||
|
||||
if (admin) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
const statements = await getAllStatements(locale);
|
||||
return NextResponse.json(statements);
|
||||
}
|
||||
|
||||
const statement = await getRandomActiveStatement(locale);
|
||||
return NextResponse.json(statement);
|
||||
} catch (error) {
|
||||
console.error('[political-statements] GET failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to load political statements' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, text, active = true, source } = body;
|
||||
|
||||
if (!locale || typeof text !== 'string' || !text.trim()) {
|
||||
return NextResponse.json({ error: 'locale and text are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const created = await createStatement(locale, { text: text.trim(), active, source });
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('[political-statements] POST failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to create statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, id, text, active, source } = body;
|
||||
|
||||
if (!locale || typeof id !== 'number') {
|
||||
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await updateStatement(locale, id, {
|
||||
text: typeof text === 'string' ? text.trim() : undefined,
|
||||
active,
|
||||
source,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error('[political-statements] PUT failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to update statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
return authError;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { locale, id } = body;
|
||||
|
||||
if (!locale || typeof id !== 'number') {
|
||||
return NextResponse.json({ error: 'locale and numeric id are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = await deleteStatement(locale, id);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: 'Statement not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[political-statements] DELETE failed:', error);
|
||||
return NextResponse.json({ error: 'Failed to delete statement' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
21
app/api/public-songs/route.ts
Normal file
21
app/api/public-songs/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Öffentliche, schreibgeschützte Song-Liste für das Spiel (GuessInput etc.).
|
||||
// Kein Auth, nur Lesen der nötigsten Felder.
|
||||
export async function GET() {
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
artist: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(songs);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,88 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function getCuratorAssignments(curatorId: number) {
|
||||
const [genres, specials] = await Promise.all([
|
||||
prisma.curatorGenre.findMany({
|
||||
where: { curatorId },
|
||||
select: { genreId: true },
|
||||
}),
|
||||
prisma.curatorSpecial.findMany({
|
||||
where: { curatorId },
|
||||
select: { specialId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
genreIds: new Set(genres.map(g => g.genreId)),
|
||||
specialIds: new Set(specials.map(s => s.specialId)),
|
||||
};
|
||||
}
|
||||
|
||||
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
// `song.specials` kann je nach Context entweder ein Array von
|
||||
// - `Special` (mit `id`)
|
||||
// - `SpecialSong` (mit `specialId`)
|
||||
// - `SpecialSong` (mit Relation `special.id`)
|
||||
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.id != null) return s.id;
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
|
||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
|
||||
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
|
||||
|
||||
return hasGenre || hasSpecial;
|
||||
}
|
||||
|
||||
function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
|
||||
if (context.role === 'admin') return true;
|
||||
|
||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||
const songSpecialIds = (song.specials || [])
|
||||
.map((s: any) => {
|
||||
if (s?.id != null) return s.id;
|
||||
if (s?.specialId != null) return s.specialId;
|
||||
if (s?.special?.id != null) return s.special.id;
|
||||
return undefined;
|
||||
})
|
||||
.filter((id: any): id is number => typeof id === 'number');
|
||||
|
||||
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
|
||||
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
|
||||
|
||||
return allGenresAllowed && allSpecialsAllowed;
|
||||
}
|
||||
|
||||
// Configure route to handle large file uploads
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
// Alle Zugriffe auf die Songliste erfordern Staff-Auth (Admin oder Kurator)
|
||||
const { error, context } = await requireStaffAuth(request);
|
||||
if (error || !context) return error!;
|
||||
|
||||
const songs = await prisma.song.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
@@ -26,8 +96,33 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
|
||||
let visibleSongs = songs;
|
||||
|
||||
if (context.role === 'curator') {
|
||||
const assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
visibleSongs = songs.filter(song => {
|
||||
const songGenreIds = song.genres.map(g => g.id);
|
||||
// `song.specials` ist hier ein Array von SpecialSong mit Relation `special`.
|
||||
// Es kann theoretisch verwaiste Einträge ohne `special` geben → defensiv optional chainen.
|
||||
const songSpecialIds = song.specials
|
||||
.map(ss => ss.special?.id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
|
||||
// Songs ohne Genres/Specials sind immer sichtbar
|
||||
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasGenre = songGenreIds.some(id => assignments.genreIds.has(id));
|
||||
const hasSpecial = songSpecialIds.some(id => assignments.specialIds.has(id));
|
||||
|
||||
return hasGenre || hasSpecial;
|
||||
});
|
||||
}
|
||||
|
||||
// Map to include activation count and flatten specials
|
||||
const songsWithActivations = songs.map(song => ({
|
||||
const songsWithActivations = visibleSongs.map(song => ({
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist,
|
||||
@@ -38,7 +133,10 @@ export async function GET() {
|
||||
activations: song.puzzles.length,
|
||||
puzzles: song.puzzles,
|
||||
genres: song.genres,
|
||||
specials: song.specials.map(ss => ss.special),
|
||||
// Nur Specials mit existierender Relation durchreichen, um undefinierte Einträge zu vermeiden.
|
||||
specials: song.specials
|
||||
.map(ss => ss.special)
|
||||
.filter((s): s is any => !!s),
|
||||
averageRating: song.averageRating,
|
||||
ratingCount: song.ratingCount,
|
||||
excludeFromGlobal: song.excludeFromGlobal,
|
||||
@@ -50,11 +148,11 @@ export async function GET() {
|
||||
export async function POST(request: Request) {
|
||||
console.log('[UPLOAD] Starting song upload request');
|
||||
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) {
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) {
|
||||
console.log('[UPLOAD] Authentication failed');
|
||||
return authError;
|
||||
return error!;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -63,10 +161,17 @@ export async function POST(request: Request) {
|
||||
const file = formData.get('file') as File;
|
||||
let title = '';
|
||||
let artist = '';
|
||||
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
|
||||
|
||||
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
|
||||
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
|
||||
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
|
||||
|
||||
// Apply global playlist rules:
|
||||
// - Admin: may control the flag via form data
|
||||
// - Curator: uploads are always excluded from global by default
|
||||
if (context.role === 'curator') {
|
||||
excludeFromGlobal = true;
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
console.error('[UPLOAD] No file provided');
|
||||
@@ -261,9 +366,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||
@@ -272,6 +377,69 @@ export async function PUT(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Load current song with relations for permission checks
|
||||
const existingSong = await prisma.song.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSong) {
|
||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
let effectiveGenreIds = genreIds as number[] | undefined;
|
||||
let effectiveSpecialIds = specialIds as number[] | undefined;
|
||||
|
||||
if (context.role === 'curator') {
|
||||
const assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
if (!curatorCanEditSong(context, existingSong, assignments)) {
|
||||
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Curators may assign genres, but only within their own assignments.
|
||||
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||
if (effectiveGenreIds !== undefined) {
|
||||
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
|
||||
if (invalidGenre) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only assign their own genres' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const fixedGenreIds = existingSong.genres
|
||||
.filter(g => !assignments.genreIds.has(g.id))
|
||||
.map(g => g.id);
|
||||
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
|
||||
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||
}
|
||||
|
||||
// Curators may assign specials, but only within their own assignments.
|
||||
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
|
||||
if (invalidSpecial) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Curators may only assign their own specials' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const currentSpecials = await prisma.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
const fixedSpecialIds = currentSpecials
|
||||
.map(ss => ss.specialId)
|
||||
.filter(sid => !assignments.specialIds.has(sid));
|
||||
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
|
||||
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||
}
|
||||
}
|
||||
|
||||
const data: any = { title, artist };
|
||||
|
||||
// Update releaseYear if provided (can be null to clear it)
|
||||
@@ -280,24 +448,36 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
if (excludeFromGlobal !== undefined) {
|
||||
if (context.role === 'admin') {
|
||||
data.excludeFromGlobal = excludeFromGlobal;
|
||||
} else {
|
||||
// Curators may only change the flag if they are global curators
|
||||
if (!context.curator.isGlobalCurator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
data.excludeFromGlobal = excludeFromGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
if (genreIds) {
|
||||
// Wenn effectiveGenreIds definiert ist, auch leere Arrays übernehmen (löscht alle Zuordnungen).
|
||||
if (effectiveGenreIds !== undefined) {
|
||||
data.genres = {
|
||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||
};
|
||||
}
|
||||
|
||||
// Handle SpecialSong relations separately
|
||||
if (specialIds !== undefined) {
|
||||
if (effectiveSpecialIds !== undefined) {
|
||||
// First, get current special assignments
|
||||
const currentSpecials = await prisma.specialSong.findMany({
|
||||
where: { songId: Number(id) }
|
||||
});
|
||||
|
||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||
const newSpecialIds = specialIds as number[];
|
||||
const newSpecialIds = effectiveSpecialIds as number[];
|
||||
|
||||
// Delete removed specials
|
||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||
@@ -344,9 +524,9 @@ export async function PUT(request: Request) {
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
// Check authentication
|
||||
const authError = await requireAdminAuth(request as any);
|
||||
if (authError) return authError;
|
||||
// Check authentication (admin or curator)
|
||||
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||
if (error || !context) return error!;
|
||||
|
||||
try {
|
||||
const { id } = await request.json();
|
||||
@@ -355,15 +535,30 @@ export async function DELETE(request: Request) {
|
||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get song to find filename
|
||||
// Get song to find filename and relations for permission checks
|
||||
const song = await prisma.song.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: {
|
||||
genres: true,
|
||||
specials: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!song) {
|
||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (context.role === 'curator') {
|
||||
const assignments = await getCuratorAssignments(context.curator.id);
|
||||
|
||||
if (!curatorCanDeleteSong(context, song, assignments)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Forbidden: You are not allowed to delete this song' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete file
|
||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||
try {
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { PrismaClient, Special } from '@prisma/client';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/auth';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
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({
|
||||
orderBy: { name: 'asc' },
|
||||
// orderBy: { name: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -25,10 +39,15 @@ export async function POST(request: Request) {
|
||||
if (!name) {
|
||||
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({
|
||||
data: {
|
||||
name,
|
||||
subtitle: subtitle || null,
|
||||
name: nameData,
|
||||
subtitle: subtitleData,
|
||||
maxAttempts: Number(maxAttempts),
|
||||
unlockSteps,
|
||||
launchDate: launchDate ? new Date(launchDate) : null,
|
||||
@@ -61,17 +80,19 @@ export async function PUT(request: Request) {
|
||||
if (!id) {
|
||||
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({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(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,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
78
app/api/version/route.ts
Normal file
78
app/api/version/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// First check if version file exists (Docker deployment)
|
||||
// Try both /app/version.txt (Docker) and ./version.txt (local)
|
||||
const versionPaths = [
|
||||
'/app/version.txt',
|
||||
join(process.cwd(), 'version.txt')
|
||||
];
|
||||
|
||||
for (const versionFilePath of versionPaths) {
|
||||
if (existsSync(versionFilePath)) {
|
||||
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
||||
if (version && version !== 'unknown' && version !== '') {
|
||||
return NextResponse.json({ version });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check environment variable
|
||||
if (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)
|
||||
let version = 'dev';
|
||||
|
||||
try {
|
||||
// First try to get the exact tag if we're on a tagged commit
|
||||
version = execSync('git describe --tags --exact-match 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd()
|
||||
}).trim();
|
||||
} catch {
|
||||
try {
|
||||
// If not on a tag, get the latest tag with commit info
|
||||
version = execSync('git describe --tags --always 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd()
|
||||
}).trim();
|
||||
} catch {
|
||||
// If git is not available or no tags exist, try to get commit hash
|
||||
try {
|
||||
const hash = execSync('git rev-parse --short HEAD 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd()
|
||||
}).trim();
|
||||
version = `dev-${hash}`;
|
||||
} catch {
|
||||
// Fallback to just 'dev' if git is not available
|
||||
version = 'dev';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ version });
|
||||
} catch (error) {
|
||||
console.error('Error getting version:', error);
|
||||
return NextResponse.json({ version: 'unknown' });
|
||||
}
|
||||
}
|
||||
1332
app/curator/page.tsx
Normal file
1332
app/curator/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,24 @@
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--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 {
|
||||
@@ -51,13 +69,13 @@ body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Audio Player */
|
||||
.audio-player {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
@@ -73,8 +91,8 @@ body {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,19 +103,20 @@ body {
|
||||
|
||||
.play-button:hover {
|
||||
background: #333;
|
||||
/* Keep for now or add --primary-hover */
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
flex: 1;
|
||||
height: 0.5rem;
|
||||
background: #d1d5db;
|
||||
background: var(--input);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: #22c55e;
|
||||
background: var(--success);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
@@ -114,7 +133,7 @@ body {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -125,7 +144,7 @@ body {
|
||||
}
|
||||
|
||||
.guess-text {
|
||||
color: #ef4444;
|
||||
color: var(--danger);
|
||||
/* Red for wrong */
|
||||
}
|
||||
|
||||
@@ -135,7 +154,7 @@ body {
|
||||
}
|
||||
|
||||
.guess-text.correct {
|
||||
color: #22c55e;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
@@ -148,14 +167,14 @@ body {
|
||||
.guess-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.guess-input:focus {
|
||||
outline: 2px solid #000;
|
||||
outline: 2px solid var(--ring);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@@ -163,7 +182,7 @@ body {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
max-height: 15rem;
|
||||
@@ -177,11 +196,11 @@ body {
|
||||
.suggestion-item {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
border-bottom: 1px solid var(--muted);
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
@@ -190,14 +209,14 @@ body {
|
||||
|
||||
.suggestion-artist {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.skip-button {
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-top: 1rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
@@ -246,7 +265,7 @@ body {
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: #f3f4f6;
|
||||
background: var(--muted);
|
||||
padding: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -265,14 +284,14 @@ body {
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border: 1px solid var(--input);
|
||||
border-radius: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -292,8 +311,8 @@ body {
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #4b5563;
|
||||
color: #fff;
|
||||
background: var(--secondary);
|
||||
color: var(--secondary-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -312,8 +331,8 @@ body {
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
color: var(--danger-foreground);
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
@@ -337,8 +356,8 @@ body {
|
||||
padding: 2rem 1rem 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: var(--muted-foreground);
|
||||
border-top: 1px solid var(--border);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -347,7 +366,7 @@ body {
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: #000;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -375,7 +394,7 @@ body {
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.statistics-grid {
|
||||
@@ -391,7 +410,7 @@ body {
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat-badge {
|
||||
@@ -401,7 +420,7 @@ body {
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -409,7 +428,7 @@ body {
|
||||
.stat-count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
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";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<InstallPrompt />
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Vibe coded with ☕ and 🍺 by{' '}
|
||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
||||
@elpatron@digitalcourage.social
|
||||
</a>
|
||||
{' '}- for personal use among friends only!
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: 'Hördle',
|
||||
short_name: 'Hördle',
|
||||
description: 'Daily music guessing game - Guess the song from short audio clips',
|
||||
name: config.appName,
|
||||
short_name: config.appName,
|
||||
description: config.appDescription,
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#ffffff',
|
||||
theme_color: '#000000',
|
||||
background_color: config.colors.backgroundColor,
|
||||
theme_color: config.colors.themeColor,
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
|
||||
99
app/page.tsx
99
app/page.tsx
@@ -1,99 +0,0 @@
|
||||
import Game from '@/components/Game';
|
||||
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>
|
||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
app/robots.ts
Normal file
20
app/robots.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/admin/', '/api/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
|
||||
136
app/sitemap.ts
Normal file
136
app/sitemap.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
const siteUrl = `${protocol}://${baseUrl}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Static pages
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${siteUrl}/en`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1.0,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de`,
|
||||
'en': `${siteUrl}/en`,
|
||||
'x-default': `${siteUrl}/en`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/en/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/about`,
|
||||
'en': `${siteUrl}/en/about`,
|
||||
'x-default': `${siteUrl}/en/about`,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Dynamic genre pages
|
||||
try {
|
||||
// Während des Docker-Builds wird häufig eine temporäre SQLite-DB (file:./dev.db)
|
||||
// ohne migrierte Tabellen verwendet. In diesem Fall überspringen wir die
|
||||
// Datenbankabfrage und liefern nur die statischen Seiten, um Build-Fehler zu vermeiden.
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (dbUrl && dbUrl.startsWith('file:./')) {
|
||||
return staticPages;
|
||||
}
|
||||
|
||||
const genres = await prisma.genre.findMany({
|
||||
where: { active: true },
|
||||
});
|
||||
|
||||
const genrePages: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const genre of genres) {
|
||||
const genreNameEn = getLocalizedValue(genre.name, 'en');
|
||||
const genreNameDe = getLocalizedValue(genre.name, 'de');
|
||||
|
||||
// Only add if genre name is valid
|
||||
if (genreNameEn && genreNameDe) {
|
||||
const encodedEn = encodeURIComponent(genreNameEn);
|
||||
const encodedDe = encodeURIComponent(genreNameDe);
|
||||
|
||||
genrePages.push(
|
||||
{
|
||||
url: `${siteUrl}/en/${encodedEn}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/de/${encodedDe}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: {
|
||||
'de': `${siteUrl}/de/${encodedDe}`,
|
||||
'en': `${siteUrl}/en/${encodedEn}`,
|
||||
'x-default': `${siteUrl}/en/${encodedEn}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [...staticPages, ...genrePages];
|
||||
} catch (error) {
|
||||
console.error('Error generating sitemap:', error);
|
||||
// Return static pages only if database query fails
|
||||
return staticPages;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import Game from '@/components/Game';
|
||||
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({ 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>
|
||||
<Game
|
||||
dailyPuzzle={dailyPuzzle}
|
||||
genre={decodedName}
|
||||
isSpecial={true}
|
||||
maxAttempts={dailyPuzzle?.maxAttempts}
|
||||
unlockSteps={dailyPuzzle?.unlockSteps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
components/AppFooter.tsx
Normal file
47
components/AppFooter.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { config } from "@/lib/config";
|
||||
import { Link } from "@/lib/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function AppFooter() {
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const t = useTranslations("About");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/version")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setVersion(data.version))
|
||||
.catch(() => setVersion(""));
|
||||
}, []);
|
||||
|
||||
if (!config.credits.enabled) return null;
|
||||
|
||||
return (
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
{config.credits.text}{" "}
|
||||
<a
|
||||
href={config.credits.linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{config.credits.linkText}
|
||||
</a>
|
||||
{version && (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
interface AudioPlayerProps {
|
||||
src: string;
|
||||
@@ -9,39 +9,112 @@ interface AudioPlayerProps {
|
||||
onPlay?: () => void;
|
||||
onReplay?: () => void;
|
||||
autoPlay?: boolean;
|
||||
onHasPlayedChange?: (hasPlayed: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
|
||||
export interface AudioPlayerRef {
|
||||
play: () => void;
|
||||
}
|
||||
|
||||
const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false, onHasPlayedChange }, ref) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
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(() => {
|
||||
if (audioRef.current) {
|
||||
// Check if props changed compared to what we last processed
|
||||
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
|
||||
|
||||
if (hasChanged) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = startTime;
|
||||
|
||||
let startPos = startTime;
|
||||
|
||||
// If same song but more time unlocked, start from where previous segment ended
|
||||
if (src === processedSrc && unlockedSeconds > processedUnlockedSeconds) {
|
||||
startPos = startTime + processedUnlockedSeconds;
|
||||
}
|
||||
|
||||
const targetPos = startPos;
|
||||
audioRef.current.currentTime = targetPos;
|
||||
|
||||
// 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);
|
||||
setProgress(0);
|
||||
|
||||
// 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) {
|
||||
const playPromise = audioRef.current.play();
|
||||
// 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
|
||||
useImperativeHandle(ref, () => ({
|
||||
play: () => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
const playPromise = audioRef.current.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
if (!hasPlayedOnce) {
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Play failed:", error);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.current) return;
|
||||
@@ -56,6 +129,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
onReplay?.();
|
||||
} else {
|
||||
setHasPlayedOnce(true);
|
||||
onHasPlayedChange?.(true); // Notify parent
|
||||
}
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
@@ -112,4 +186,10 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
AudioPlayer.displayName = 'AudioPlayer';
|
||||
|
||||
|
||||
|
||||
export default AudioPlayer;
|
||||
|
||||
98
components/ExtraPuzzlesPopover.tsx
Normal file
98
components/ExtraPuzzlesPopover.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
|
||||
interface ExtraPuzzlesPopoverProps {
|
||||
puzzle: ExternalPuzzle;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ExtraPuzzlesPopover({ puzzle, onClose }: ExtraPuzzlesPopoverProps) {
|
||||
const t = useTranslations('ExtraPuzzles');
|
||||
const locale = useLocale();
|
||||
|
||||
const name = locale === 'de' ? puzzle.nameDe : puzzle.nameEn;
|
||||
|
||||
const handleClick = () => {
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('extra_puzzles_click', {
|
||||
props: {
|
||||
partner: puzzle.id,
|
||||
url: puzzle.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '1.5rem',
|
||||
right: '1.5rem',
|
||||
zIndex: 1100,
|
||||
maxWidth: '320px',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
|
||||
borderRadius: '0.75rem',
|
||||
background: 'white',
|
||||
padding: '1rem 1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>
|
||||
{t('title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label={t('close')}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1,
|
||||
color: '#6b7280',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ margin: 0, fontSize: '0.9rem', color: '#4b5563' }}>
|
||||
{t('message', { name })}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href={puzzle.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.4rem',
|
||||
marginTop: '0.25rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #4f46e5, #ec4899)',
|
||||
color: 'white',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{t('cta', { name })}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
import { config } from '@/lib/config';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||
import GuessInput from './GuessInput';
|
||||
import Statistics from './Statistics';
|
||||
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||
import { useGameState } from '../lib/gameState';
|
||||
import { getGenreKey } from '@/lib/playerStorage';
|
||||
import type { ExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
import { getRandomExternalPuzzle } from '@/lib/externalPuzzles';
|
||||
import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker';
|
||||
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||
|
||||
// Plausible Analytics
|
||||
declare global {
|
||||
interface Window {
|
||||
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||
}
|
||||
}
|
||||
|
||||
interface GameProps {
|
||||
dailyPuzzle: {
|
||||
id: number;
|
||||
@@ -23,20 +37,29 @@ interface GameProps {
|
||||
isSpecial?: boolean;
|
||||
maxAttempts?: number;
|
||||
unlockSteps?: number[];
|
||||
// List of genre keys that zusammen alle Tagesrätsel des Tages repräsentieren (z. B. ['global', 'Rock', 'Pop']).
|
||||
// Wird genutzt, um zu prüfen, ob der Spieler alle Tagesrätsel gespielt hat.
|
||||
requiredDailyKeys?: string[];
|
||||
}
|
||||
|
||||
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) {
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
|
||||
const t = useTranslations('Game');
|
||||
const locale = useLocale();
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||
const [hasWon, setHasWon] = 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 [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [showYearModal, setShowYearModal] = useState(false);
|
||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -68,13 +91,44 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}, [gameState, dailyPuzzle]);
|
||||
|
||||
// Track gespielte Tagesrätsel & entscheide, ob das Partner-Popover gezeigt werden soll
|
||||
useEffect(() => {
|
||||
if (!gameState || !dailyPuzzle) return;
|
||||
|
||||
const gameEnded = gameState.isSolved || gameState.isFailed;
|
||||
if (!gameEnded) return;
|
||||
|
||||
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
markDailyPuzzlePlayedToday(genreKey);
|
||||
|
||||
if (!requiredDailyKeys || requiredDailyKeys.length === 0) return;
|
||||
if (hasSeenExtraPuzzlesPopoverToday()) return;
|
||||
if (!hasPlayedAllDailyPuzzlesForToday(requiredDailyKeys)) return;
|
||||
|
||||
const partnerPuzzle = getRandomExternalPuzzle();
|
||||
if (!partnerPuzzle) return;
|
||||
|
||||
setExtraPuzzle(partnerPuzzle);
|
||||
setShowExtraPuzzlesPopover(true);
|
||||
markExtraPuzzlesPopoverShownToday();
|
||||
|
||||
if (typeof window !== 'undefined' && window.plausible) {
|
||||
window.plausible('extra_puzzles_popover_shown', {
|
||||
props: {
|
||||
partner: partnerPuzzle.id,
|
||||
url: partnerPuzzle.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [gameState?.isSolved, gameState?.isFailed, dailyPuzzle?.id, genre, isSpecial, requiredDailyKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setLastAction(null);
|
||||
}, [dailyPuzzle?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
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)) {
|
||||
setHasRated(true);
|
||||
} else {
|
||||
@@ -85,22 +139,37 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
if (!dailyPuzzle) return (
|
||||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<h2>No Puzzle Available</h2>
|
||||
<p>Could not generate a daily puzzle.</p>
|
||||
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
||||
<h2>{t('noPuzzleAvailable')}</h2>
|
||||
<p>{t('noPuzzleDescription')}</p>
|
||||
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
|
||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
|
||||
</div>
|
||||
);
|
||||
if (!gameState) return <div>Loading state...</div>;
|
||||
if (!gameState) return <div>{t('loadingState')}</div>;
|
||||
|
||||
const handleGuess = (song: any) => {
|
||||
if (isProcessingGuess) return;
|
||||
// Prevent guessing if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessingGuess(true);
|
||||
setLastAction('GUESS');
|
||||
if (song.id === dailyPuzzle.songId) {
|
||||
addGuess(song.title, 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
|
||||
if (!dailyPuzzle.releaseYear) {
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
@@ -110,29 +179,81 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
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
|
||||
}
|
||||
}
|
||||
setTimeout(() => setIsProcessingGuess(false), 500);
|
||||
};
|
||||
|
||||
const handleStartAudio = () => {
|
||||
// This will be called when user clicks "Start" button on first attempt
|
||||
// Trigger the audio player to start playing
|
||||
audioPlayerRef.current?.play();
|
||||
setHasPlayedAudio(true);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
// Prevent skipping if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
// If user hasn't played audio yet on first attempt, start it instead of skipping
|
||||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||
handleStartAudio();
|
||||
return;
|
||||
}
|
||||
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
const handleGiveUp = () => {
|
||||
// Prevent giving up if already solved or failed
|
||||
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||
setHasLost(true);
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -141,6 +262,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
addYearBonus(correct);
|
||||
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
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||
};
|
||||
@@ -148,6 +282,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const handleYearSkip = () => {
|
||||
skipYearBonus();
|
||||
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
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
@@ -160,23 +308,33 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
for (let i = 0; i < totalGuesses; i++) {
|
||||
if (i < gameState.guesses.length) {
|
||||
if (hasWon && i === gameState.guesses.length - 1) {
|
||||
emojiGrid += '🟩';
|
||||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
||||
if (gameState.guesses[i] === 'SKIPPED') {
|
||||
emojiGrid += '⬛';
|
||||
} else if (hasWon && i === gameState.guesses.length - 1) {
|
||||
emojiGrid += '🟩';
|
||||
} else {
|
||||
emojiGrid += '🟥';
|
||||
}
|
||||
} else {
|
||||
emojiGrid += '⬜';
|
||||
// If game is lost, fill remaining slots with black squares
|
||||
emojiGrid += hasLost ? '⬛' : '⬜';
|
||||
}
|
||||
}
|
||||
|
||||
const speaker = hasWon ? '🔉' : '🔇';
|
||||
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';
|
||||
// Use current domain from window.location to support both hoerdle.de and hördle.de,
|
||||
// but always share the pretty Unicode-Domain "hördle.de" instead of the Punycode variant.
|
||||
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||
const currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||
let shareUrl = `${protocol}//${currentHost}`;
|
||||
// Add locale prefix if not default (en)
|
||||
if (locale !== 'en') {
|
||||
shareUrl += `/${locale}`;
|
||||
}
|
||||
if (genre) {
|
||||
if (isSpecial) {
|
||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||
@@ -185,7 +343,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);
|
||||
|
||||
@@ -195,8 +353,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
||||
text: text,
|
||||
});
|
||||
setShareText('✓ Shared!');
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
setShareText(t('shared'));
|
||||
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||
return;
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
@@ -207,12 +365,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setShareText('✓ Copied!');
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
setShareText(t('copied'));
|
||||
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||
} catch (err) {
|
||||
console.error('Clipboard failed:', err);
|
||||
setShareText('✗ Failed');
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
setShareText(t('shareFailed'));
|
||||
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,42 +381,55 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||
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)) {
|
||||
ratedPuzzles.push(dailyPuzzle.id);
|
||||
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
|
||||
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to submit rating', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Aktuelle Attempt-Anzeige:
|
||||
// - Während des Spiels: nächster Versuch = guesses.length + 1
|
||||
// - Nach Spielende (gelöst oder verloren): letzter Versuch = guesses.length
|
||||
const currentAttempt = (gameState.isSolved || gameState.isFailed)
|
||||
? gameState.guesses.length
|
||||
: gameState.guesses.length + 1;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '-0.5rem', marginBottom: '1rem' }}>
|
||||
Next puzzle in: {timeUntilNext}
|
||||
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||
{t('nextPuzzle')}: {timeUntilNext}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="game-board">
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div className="status-bar">
|
||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s unlocked</span>
|
||||
<div id="tour-status" className="status-bar">
|
||||
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||
</div>
|
||||
|
||||
<div id="tour-score">
|
||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||
</div>
|
||||
|
||||
<div id="tour-player">
|
||||
<AudioPlayer
|
||||
ref={audioPlayerRef}
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||
onReplay={addReplay}
|
||||
onHasPlayedChange={setHasPlayedAudio}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="guess-list">
|
||||
{gameState.guesses.map((guess, i) => {
|
||||
@@ -267,7 +438,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
<div key={i} className="guess-item">
|
||||
<span className="guess-number">#{i + 1}</span>
|
||||
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
||||
{isCorrect ? 'Correct!' : guess}
|
||||
{isCorrect ? t('correct') : guess}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -276,13 +447,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
|
||||
{!hasWon && !hasLost && (
|
||||
<>
|
||||
<div id="tour-input">
|
||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||
</div>
|
||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||
<button
|
||||
id="tour-controls"
|
||||
onClick={handleSkip}
|
||||
className="skip-button"
|
||||
>
|
||||
Skip (+{unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)
|
||||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||
? t('start')
|
||||
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
|
||||
}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -293,7 +470,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
||||
}}
|
||||
>
|
||||
Solve (Give Up)
|
||||
{t('solveGiveUp')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -302,15 +479,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{(hasWon || hasLost) && (
|
||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{hasWon ? 'You won!' : 'Game Over'}
|
||||
{hasWon ? t('won') : t('lost')}
|
||||
</h2>
|
||||
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
||||
Score: {gameState.score}
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
|
||||
{t('score')}: {gameState.score}
|
||||
</div>
|
||||
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||||
<summary>Score Breakdown</summary>
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
|
||||
<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' }}>
|
||||
{gameState.scoreBreakdown.map((item, i) => (
|
||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||
@@ -323,34 +500,40 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</ul>
|
||||
</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' }}>
|
||||
<img
|
||||
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)' }}
|
||||
/>
|
||||
<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 && (
|
||||
<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%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
{t('yourBrowserDoesNotSupport')}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||
<div style={{ marginBottom: '1.25rem', textAlign: 'center' }}>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>
|
||||
{t('shareExplanation')}
|
||||
</p>
|
||||
<button onClick={handleShare} className="btn-primary">
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -361,6 +544,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||
<ExtraPuzzlesPopover
|
||||
puzzle={extraPuzzle}
|
||||
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -385,20 +575,22 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
||||
textAlign: 'center',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem',
|
||||
background: '#f3f4f6',
|
||||
background: 'var(--muted)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||
const t = useTranslations('Game');
|
||||
const [options, setOptions] = useState<number[]>([]);
|
||||
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
|
||||
|
||||
useEffect(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -427,6 +619,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||
}, [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 (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
@@ -450,8 +660,10 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
textAlign: 'center',
|
||||
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>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
||||
{!feedback.show ? (
|
||||
<>
|
||||
<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',
|
||||
@@ -462,20 +674,20 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
{options.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => onGuess(year)}
|
||||
onClick={() => handleGuess(year)}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
background: 'var(--muted)',
|
||||
border: '2px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
color: 'var(--secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
@@ -483,34 +695,67 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
color: 'var(--muted-foreground)',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Skip Bonus
|
||||
{t('skipBonus')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
{feedback.guessedYear ? (
|
||||
feedback.correct ? (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>{t('correct')}</h3>
|
||||
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 {t('points')}!</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||||
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>{t('notQuite')}</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
||||
const t = useTranslations('Game');
|
||||
const [hover, setHover] = useState(0);
|
||||
const [rating, setRating] = useState(0);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<div
|
||||
className="star-rating"
|
||||
title={t('ratingTooltip')}
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', fontWeight: '500' }}>{t('rateThisPuzzle')}</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||
{[...Array(5)].map((_, index) => {
|
||||
const ratingValue = index + 1;
|
||||
@@ -523,7 +768,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '2rem',
|
||||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
||||
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
|
||||
transition: 'color 0.2s',
|
||||
padding: '0 0.25rem'
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface Song {
|
||||
id: number;
|
||||
@@ -14,15 +15,32 @@ interface GuessInputProps {
|
||||
}
|
||||
|
||||
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||
const t = useTranslations('Game');
|
||||
const [query, setQuery] = useState('');
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/songs')
|
||||
.then(res => res.json())
|
||||
.then(data => setSongs(data));
|
||||
fetch('/api/public-songs')
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load songs: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (Array.isArray(data)) {
|
||||
setSongs(data);
|
||||
} else {
|
||||
console.error('Unexpected songs payload in GuessInput:', data);
|
||||
setSongs([]);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading songs for GuessInput:', err);
|
||||
setSongs([]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -53,7 +71,7 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
|
||||
placeholder={disabled ? t('gameOverPlaceholder') : t('knowItSearch')}
|
||||
className="guess-input"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function InstallPrompt() {
|
||||
const t = useTranslations('InstallPrompt');
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [isStandalone, setIsStandalone] = 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>
|
||||
<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' }}>
|
||||
Install the app for a better experience and quick access!
|
||||
{t('installDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -102,7 +104,7 @@ export default function InstallPrompt() {
|
||||
|
||||
{isIOS ? (
|
||||
<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>
|
||||
) : (
|
||||
<button
|
||||
@@ -118,7 +120,7 @@ export default function InstallPrompt() {
|
||||
marginTop: '0.5rem'
|
||||
}}
|
||||
>
|
||||
Install App
|
||||
{t('installButton')}
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
204
components/NewsSection.tsx
Normal file
204
components/NewsSection.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Link } from '@/lib/navigation';
|
||||
import { getLocalizedValue } from '@/lib/i18n';
|
||||
|
||||
interface NewsItem {
|
||||
id: number;
|
||||
title: any;
|
||||
content: any;
|
||||
author: string | null;
|
||||
publishedAt: string;
|
||||
featured: boolean;
|
||||
special: {
|
||||
id: number;
|
||||
name: any;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface NewsSectionProps {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export default function NewsSection({ locale }: NewsSectionProps) {
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNews();
|
||||
}, [locale]);
|
||||
|
||||
const fetchNews = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/news?limit=3&locale=${locale}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNews(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch news:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || news.length === 0) {
|
||||
return null; // Don't show anything if no news
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#f9fafb',
|
||||
borderRadius: '0.5rem',
|
||||
margin: '1rem auto',
|
||||
maxWidth: '800px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}
|
||||
>
|
||||
<span>📰 News & Updates</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
padding: '0 1rem 1rem 1rem',
|
||||
borderTop: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{news.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: index < news.length - 1 ? '1px solid #e5e7eb' : 'none'
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.25rem'
|
||||
}}>
|
||||
{item.featured && (
|
||||
<span style={{
|
||||
background: '#fef3c7',
|
||||
color: '#92400e',
|
||||
padding: '0.125rem 0.375rem',
|
||||
borderRadius: '0.25rem',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
⭐ FEATURED
|
||||
</span>
|
||||
)}
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: '600',
|
||||
color: '#111827'
|
||||
}}>
|
||||
{getLocalizedValue(item.title, locale)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div style={{
|
||||
fontSize: '0.75rem',
|
||||
color: '#6b7280',
|
||||
marginBottom: '0.5rem',
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<span>
|
||||
{new Date(item.publishedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
{item.author && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>by {item.author}</span>
|
||||
</>
|
||||
)}
|
||||
{item.special && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<Link
|
||||
href={`/special/${getLocalizedValue(item.special.name, locale)}`}
|
||||
style={{
|
||||
color: '#be185d',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
★ {getLocalizedValue(item.special.name, locale)}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="news-content"
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
color: '#374151',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
p: ({ children }) => <p style={{ margin: '0.5rem 0' }}>{children}</p>,
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#4f46e5', textDecoration: 'underline' }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => <strong style={{ fontWeight: '600' }}>{children}</strong>,
|
||||
em: ({ children }) => <em style={{ fontStyle: 'italic' }}>{children}</em>,
|
||||
ul: ({ children }) => <ul style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ul>,
|
||||
ol: ({ children }) => <ol style={{ margin: '0.5rem 0', paddingLeft: '1.5rem' }}>{children}</ol>,
|
||||
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
||||
}}
|
||||
>
|
||||
{getLocalizedValue(item.content, locale)}
|
||||
</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;
|
||||
}
|
||||
95
components/PoliticalStatementBanner.tsx
Normal file
95
components/PoliticalStatementBanner.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
interface ApiStatement {
|
||||
id: number;
|
||||
text: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export default function PoliticalStatementBanner() {
|
||||
const locale = useLocale();
|
||||
const [statement, setStatement] = useState<ApiStatement | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const storageKey = `hoerdle_political_statement_shown_${today}_${locale}`;
|
||||
|
||||
try {
|
||||
const alreadyShown = typeof window !== 'undefined' && window.localStorage.getItem(storageKey);
|
||||
if (alreadyShown) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore localStorage errors
|
||||
}
|
||||
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
const fetchStatement = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/political-statements?locale=${encodeURIComponent(locale)}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (!data || !data.text) return;
|
||||
setStatement(data);
|
||||
setVisible(true);
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
setVisible(false);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, 'true');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 5000);
|
||||
} catch (e) {
|
||||
console.warn('[PoliticalStatementBanner] Failed to load statement', e);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatement();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
if (!visible || !statement) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '1.25rem',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
maxWidth: '640px',
|
||||
width: 'calc(100% - 2.5rem)',
|
||||
zIndex: 1050,
|
||||
background: 'rgba(17,24,39,0.95)',
|
||||
color: '#e5e7eb',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '999px',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.4,
|
||||
boxShadow: '0 10px 25px rgba(0,0,0,0.45)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.9rem' }}>✊</span>
|
||||
<span style={{ flex: 1 }}>{statement.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Statistics as StatsType } from '../lib/gameState';
|
||||
|
||||
interface StatisticsProps {
|
||||
@@ -18,6 +19,7 @@ const BADGES = {
|
||||
};
|
||||
|
||||
export default function Statistics({ statistics }: StatisticsProps) {
|
||||
const t = useTranslations('Statistics');
|
||||
const total =
|
||||
statistics.solvedIn1 +
|
||||
statistics.solvedIn2 +
|
||||
@@ -36,19 +38,19 @@ export default function Statistics({ statistics }: StatisticsProps) {
|
||||
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
||||
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
||||
{ 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 (
|
||||
<div className="statistics-container">
|
||||
<h3 className="statistics-title">Your Statistics</h3>
|
||||
<p className="statistics-total">Total puzzles: {total}</p>
|
||||
<h3 className="statistics-title">{t('yourStatistics')}</h3>
|
||||
<p className="statistics-total">{t('totalPuzzles')}: {total}</p>
|
||||
<div className="statistics-grid">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="stat-item">
|
||||
<div className="stat-badge">{stat.badge}</div>
|
||||
<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 className="stat-count">{stat.count}</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,17 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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_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
|
||||
restart: always
|
||||
ports:
|
||||
@@ -24,6 +35,11 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
# Run migrations and start server (auto-baseline on first run if needed)
|
||||
command: >
|
||||
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"
|
||||
networks:
|
||||
- default
|
||||
# docker-entrypoint.sh handles migrations and server startup (with baseline fallback)
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hoerdle_default
|
||||
external: true
|
||||
|
||||
289
docs/CADDY_SETUP.md
Normal file
289
docs/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)
|
||||
|
||||
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
183
docs/CADDY_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Caddy Zertifikat-Troubleshooting
|
||||
|
||||
## Problem: Zertifikat für Punycode-Domain (hördle.de / xn--hrdle-jua.de) fehlt
|
||||
|
||||
Wenn die Domain `hördle.de` (xn--hrdle-jua.de) einen `ERR_SSL_PROTOCOL_ERROR` zeigt, bedeutet das, dass kein gültiges SSL-Zertifikat vorhanden ist.
|
||||
|
||||
### Schritt 1: Zertifikat-Status prüfen
|
||||
|
||||
Führe das Check-Script aus:
|
||||
|
||||
```bash
|
||||
./scripts/check-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Dieses Script prüft:
|
||||
- Ob Caddy läuft
|
||||
- Welche Zertifikate vorhanden sind
|
||||
- Ob die DNS-Einträge korrekt sind
|
||||
- Ob die HTTPS-Verbindungen funktionieren
|
||||
|
||||
### Schritt 2: DNS-Einträge prüfen
|
||||
|
||||
**Wichtig**: Beide Domains müssen auf die gleiche Server-IP zeigen!
|
||||
|
||||
#### In GoDaddy prüfen:
|
||||
|
||||
1. Gehe zu [GoDaddy DNS-Verwaltung](https://dcc.godaddy.com/manage/hoerdle.de/dns)
|
||||
2. Prüfe die A-Records:
|
||||
|
||||
**Für hoerdle.de:**
|
||||
- Name: `@` oder `hoerdle.de`
|
||||
- Typ: `A`
|
||||
- Wert: `DEINE_SERVER_IP`
|
||||
|
||||
**Für hördle.de (Punycode):**
|
||||
- Name: `@` oder `xn--hrdle-jua.de` (oder der Unicode-Name, falls unterstützt)
|
||||
- Typ: `A`
|
||||
- Wert: **GLEICHE_SERVER_IP wie hoerdle.de**
|
||||
|
||||
#### DNS manuell testen:
|
||||
|
||||
```bash
|
||||
# Prüfe hoerdle.de
|
||||
dig +short hoerdle.de @8.8.8.8
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
dig +short xn--hrdle-jua.de @8.8.8.8
|
||||
|
||||
# Beide sollten die gleiche IP zurückgeben!
|
||||
```
|
||||
|
||||
### Schritt 3: Zertifikat neu erstellen
|
||||
|
||||
Wenn die DNS-Einträge korrekt sind, lösche das alte (fehlgeschlagene) Zertifikat und lass Caddy es neu erstellen:
|
||||
|
||||
```bash
|
||||
./scripts/renew-caddy-certificates.sh
|
||||
```
|
||||
|
||||
Wähle Option 2: "Nur Zertifikat für xn--hrdle-jua.de löschen"
|
||||
|
||||
### Schritt 4: Caddy-Logs überwachen
|
||||
|
||||
Während Caddy das Zertifikat erstellt, überwache die Logs:
|
||||
|
||||
```bash
|
||||
docker logs hoerdle-caddy -f
|
||||
```
|
||||
|
||||
Du solltest sehen:
|
||||
- `[INFO] attempting ACME challenge` - Caddy versucht die Challenge
|
||||
- `[INFO] successfully completed ACME challenge` - Challenge erfolgreich
|
||||
- `[INFO] certificate obtained successfully` - Zertifikat erstellt
|
||||
|
||||
Bei Fehlern siehst du:
|
||||
- `[ERROR] acme: error` - Challenge fehlgeschlagen
|
||||
- `[ERROR] unable to validate` - Validierung fehlgeschlagen
|
||||
|
||||
### Schritt 5: Häufige Probleme und Lösungen
|
||||
|
||||
#### Problem 1: DNS zeigt auf falsche IP
|
||||
|
||||
**Symptom**: `dig` zeigt eine andere IP als erwartet
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe DNS-Einträge in GoDaddy
|
||||
2. Warte auf DNS-Propagierung (kann 5-60 Minuten dauern)
|
||||
3. Verwende einen DNS-Checker: https://www.whatsmydns.net/
|
||||
|
||||
#### Problem 2: Port 80 nicht erreichbar
|
||||
|
||||
**Symptom**: Caddy-Logs zeigen "connection refused" oder Timeout
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe Firewall: `sudo ufw status`
|
||||
2. Prüfe ob Port 80 offen ist: `sudo netstat -tulpn | grep :80`
|
||||
3. Prüfe ob Caddy auf Port 80 lauscht: `docker exec hoerdle-caddy netstat -tulpn | grep :80`
|
||||
|
||||
#### Problem 3: Let's Encrypt Rate Limit
|
||||
|
||||
**Symptom**: Logs zeigen "too many certificates already issued"
|
||||
|
||||
**Lösung**:
|
||||
- Warte 1 Woche (Rate Limit von Let's Encrypt)
|
||||
- Oder verwende Staging-Environment zum Testen:
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
#### Problem 4: Punycode-Domain wird nicht erkannt
|
||||
|
||||
**Symptom**: Caddy erstellt Zertifikat nur für hoerdle.de, nicht für xn--hrdle-jua.de
|
||||
|
||||
**Lösung**:
|
||||
1. Prüfe ob beide Domains in der Caddyfile stehen
|
||||
2. Prüfe DNS-Einträge (siehe Schritt 2)
|
||||
3. Erzwinge Zertifikat-Erstellung (siehe Schritt 3)
|
||||
|
||||
### Manuelle Zertifikat-Löschung
|
||||
|
||||
Falls das Script nicht funktioniert, kannst du Zertifikate manuell löschen:
|
||||
|
||||
```bash
|
||||
# Alle Zertifikate löschen
|
||||
docker exec hoerdle-caddy rm -rf /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/*
|
||||
|
||||
# Nur Punycode-Zertifikat löschen (manuell)
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*xn--*" -delete
|
||||
|
||||
# Container neu starten
|
||||
docker compose -f docker-compose.caddy.yml --profile production restart caddy
|
||||
```
|
||||
|
||||
### DNS-Propagierung prüfen
|
||||
|
||||
Nach DNS-Änderungen kann es bis zu 60 Minuten dauern, bis alle DNS-Server aktualisiert sind:
|
||||
|
||||
```bash
|
||||
# Prüfe DNS-Propagierung weltweit
|
||||
curl "https://dnschecker.org/#A/hoerdle.de"
|
||||
curl "https://dnschecker.org/#A/xn--hrdle-jua.de"
|
||||
```
|
||||
|
||||
### Test-Zertifikat erstellen (Staging)
|
||||
|
||||
Zum Testen ohne Rate-Limits kannst du ein Staging-Zertifikat erstellen:
|
||||
|
||||
1. Temporär Caddyfile ändern (in beiden Domain-Blocks):
|
||||
```caddyfile
|
||||
tls {
|
||||
staging
|
||||
}
|
||||
```
|
||||
|
||||
2. Container neu starten
|
||||
3. Zertifikat erstellen lassen
|
||||
4. Zurück zu Produktion ändern (Staging-Block entfernen)
|
||||
5. Erneut Container neu starten
|
||||
|
||||
### Verifizieren, dass es funktioniert
|
||||
|
||||
Nach erfolgreicher Zertifikats-Erstellung:
|
||||
|
||||
```bash
|
||||
# Teste HTTPS-Verbindung
|
||||
curl -I https://hoerdle.de
|
||||
curl -I https://xn--hrdle-jua.de
|
||||
|
||||
# Prüfe Zertifikat-Details
|
||||
echo | openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
echo | openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates
|
||||
```
|
||||
|
||||
### Support
|
||||
|
||||
Falls das Problem weiterhin besteht:
|
||||
1. Prüfe Caddy-Logs: `docker logs hoerdle-caddy`
|
||||
2. Prüfe DNS: `dig +short xn--hrdle-jua.de @8.8.8.8`
|
||||
3. Prüfe Firewall: `sudo ufw status`
|
||||
4. Prüfe Port-Zugriff: `curl -I http://hoerdle.de`
|
||||
|
||||
185
docs/DEBUG_VERSION.md
Normal file
185
docs/DEBUG_VERSION.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Debug Version Display - Remote Server Checklist
|
||||
|
||||
## 1. Überprüfe Git-Tags auf dem Remote-Server
|
||||
|
||||
```bash
|
||||
# Im Projekt-Verzeichnis auf dem Remote-Server
|
||||
cd /path/to/hoerdle
|
||||
|
||||
# Zeige alle Tags
|
||||
git tag -l
|
||||
|
||||
# Zeige aktuellen Tag/Version
|
||||
git describe --tags --always
|
||||
|
||||
# Wenn keine Tags angezeigt werden:
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Sollte `v0.1.0.2` oder ähnlich zeigen
|
||||
|
||||
---
|
||||
|
||||
## 2. Überprüfe die version.txt im Container
|
||||
|
||||
```bash
|
||||
# Zeige den Inhalt der Version-Datei im laufenden Container
|
||||
docker exec hoerdle cat /app/version.txt
|
||||
|
||||
# Sollte die Version zeigen, z.B. "v0.1.0.2"
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Die aktuelle Version, nicht "unknown" oder "dev"
|
||||
|
||||
---
|
||||
|
||||
## 3. Überprüfe die Umgebungsvariable im Container
|
||||
|
||||
```bash
|
||||
# Zeige alle Umgebungsvariablen
|
||||
docker exec hoerdle env | grep APP_VERSION
|
||||
|
||||
# Sollte APP_VERSION=v0.1.0.2 oder ähnlich zeigen
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `APP_VERSION=v0.1.0.2`
|
||||
|
||||
---
|
||||
|
||||
## 4. Überprüfe die Container-Logs beim Start
|
||||
|
||||
```bash
|
||||
# Zeige die letzten Logs beim Container-Start
|
||||
docker logs hoerdle | head -20
|
||||
|
||||
# Suche speziell nach Version-Ausgaben
|
||||
docker logs hoerdle | grep -i version
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "App version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## 5. Teste die API direkt
|
||||
|
||||
```bash
|
||||
# Rufe die Version-API auf
|
||||
curl http://localhost:3010/api/version
|
||||
|
||||
# Sollte JSON zurückgeben: {"version":"v0.1.0.2"}
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** `{"version":"v0.1.0.2"}`
|
||||
|
||||
---
|
||||
|
||||
## 6. Überprüfe wann der Container gebaut wurde
|
||||
|
||||
```bash
|
||||
# Zeige Image-Informationen
|
||||
docker images | grep hoerdle
|
||||
|
||||
# Zeige detaillierte Container-Informationen
|
||||
docker inspect hoerdle | grep -i created
|
||||
```
|
||||
|
||||
**Wichtig:** Wenn das Image vor deinem letzten Deployment erstellt wurde, wurde es noch nicht neu gebaut!
|
||||
|
||||
---
|
||||
|
||||
## 7. Überprüfe Build-Logs
|
||||
|
||||
```bash
|
||||
# Baue das Image neu und beobachte die Ausgabe
|
||||
docker compose build --no-cache 2>&1 | tee build.log
|
||||
|
||||
# Suche nach der Version-Ausgabe im Build
|
||||
grep -i "Building version" build.log
|
||||
```
|
||||
|
||||
**Erwartetes Ergebnis:** Eine Zeile wie "Building version: v0.1.0.2"
|
||||
|
||||
---
|
||||
|
||||
## Häufige Probleme und Lösungen
|
||||
|
||||
### Problem 1: Tags nicht auf dem Server
|
||||
```bash
|
||||
git fetch --tags
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
### Problem 2: Container wurde nicht neu gebaut
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 3: Alte version.txt im Container
|
||||
```bash
|
||||
# Stoppe Container, lösche Image, baue neu
|
||||
docker compose down
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Problem 4: .git Verzeichnis nicht im Build-Context
|
||||
```bash
|
||||
# Überprüfe ob .git existiert
|
||||
ls -la .git
|
||||
|
||||
# Überprüfe .dockerignore (sollte .git NICHT ausschließen)
|
||||
cat .dockerignore 2>/dev/null || echo "Keine .dockerignore Datei"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vollständiger Neustart (wenn nichts anderes hilft)
|
||||
|
||||
```bash
|
||||
# 1. Stoppe alles
|
||||
docker compose down
|
||||
|
||||
# 2. Lösche alte Images
|
||||
docker rmi $(docker images | grep hoerdle | awk '{print $3}')
|
||||
|
||||
# 3. Hole neueste Änderungen und Tags
|
||||
git pull
|
||||
git fetch --tags
|
||||
|
||||
# 4. Überprüfe Version lokal
|
||||
git describe --tags --always
|
||||
|
||||
# 5. Baue komplett neu
|
||||
docker compose build --no-cache
|
||||
|
||||
# 6. Starte Container
|
||||
docker compose up -d
|
||||
|
||||
# 7. Überprüfe Logs
|
||||
docker logs hoerdle | grep -i version
|
||||
|
||||
# 8. Teste API
|
||||
curl http://localhost:3010/api/version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging-Befehl für alle Checks auf einmal
|
||||
|
||||
```bash
|
||||
echo "=== Git Tags ===" && \
|
||||
git describe --tags --always && \
|
||||
echo -e "\n=== version.txt im Container ===" && \
|
||||
docker exec hoerdle cat /app/version.txt 2>/dev/null || echo "Container läuft nicht oder Datei fehlt" && \
|
||||
echo -e "\n=== APP_VERSION Env ===" && \
|
||||
docker exec hoerdle env | grep APP_VERSION || echo "Variable nicht gesetzt" && \
|
||||
echo -e "\n=== API Response ===" && \
|
||||
curl -s http://localhost:3010/api/version && \
|
||||
echo -e "\n\n=== Container Created ===" && \
|
||||
docker inspect hoerdle | grep -i created | head -1
|
||||
```
|
||||
|
||||
Kopiere diesen Befehl und führe ihn auf dem Remote-Server aus. Schicke mir die Ausgabe!
|
||||
116
docs/DEPLOYMENT.md
Normal file
116
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Automated Deployment
|
||||
|
||||
Use the deployment script for zero-downtime deployments:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Create a database backup
|
||||
2. Pull latest changes from git
|
||||
3. Fetch all git tags (for version display)
|
||||
4. Build the new Docker image
|
||||
5. Restart the container with minimal downtime
|
||||
6. Clean up old images
|
||||
|
||||
## Manual Deployment
|
||||
|
||||
If you need to deploy manually:
|
||||
|
||||
```bash
|
||||
# Pull latest changes
|
||||
git pull
|
||||
|
||||
# Fetch tags (important for version display!)
|
||||
git fetch --tags
|
||||
|
||||
# Build and restart
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Version Display
|
||||
|
||||
The app displays the current version in the footer. The version is determined as follows:
|
||||
|
||||
1. **During Docker build**: The version is extracted from git tags using `git describe --tags --always`
|
||||
2. **At runtime**: The version is read from `/app/version.txt` and exposed via the `/api/version` endpoint
|
||||
3. **Local development**: The version is extracted directly from git on each request
|
||||
|
||||
### Building with a specific version
|
||||
|
||||
You can override the version during build:
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg APP_VERSION=v1.2.3
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If the version shows as "dev" or "unknown":
|
||||
|
||||
1. Make sure git tags are pushed to the remote repository:
|
||||
```bash
|
||||
git push --tags
|
||||
```
|
||||
|
||||
2. On the deployment server, fetch the tags:
|
||||
```bash
|
||||
git fetch --tags
|
||||
```
|
||||
|
||||
3. Verify tags are available:
|
||||
```bash
|
||||
git describe --tags --always
|
||||
```
|
||||
|
||||
4. Rebuild the Docker image:
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The container includes a health check that monitors the `/api/daily` endpoint. Check the health status:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
|
||||
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
docs/DOCKER_BUILD_FIX.md
Normal file
106
docs/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
|
||||
```
|
||||
|
||||
83
docs/FIX_I18N.md
Normal file
83
docs/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
docs/I18N.md
Normal file
349
docs/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
|
||||
|
||||
- **Englisch (en)** - Standardsprache
|
||||
- **Deutsch (de)**
|
||||
|
||||
## URL-Struktur
|
||||
|
||||
Alle Routen sind lokalisiert:
|
||||
|
||||
- `http://localhost:3000/` → Redirect zu `/en` (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 `en` falls nicht vorhanden
|
||||
3. Fallback zu `de` 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:
|
||||
|
||||
- `/` → `/en` (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 `en` 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)
|
||||
|
||||
167
docs/PLAUSIBLE_SETUP.md
Normal file
167
docs/PLAUSIBLE_SETUP.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Plausible Analytics Konfiguration
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die App verwendet Plausible Analytics für anonyme Nutzungsstatistiken. Die Konfiguration erfolgt über Umgebungsvariablen.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Erforderliche Variablen
|
||||
|
||||
**Nur eine Variable ist erforderlich:**
|
||||
|
||||
1. **`NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`** (erforderlich)
|
||||
- Die vollständige URL zum Plausible-Script
|
||||
- Beispiel (selbst gehostet): `https://plausible.elpatron.me/js/script.js`
|
||||
- Beispiel (extern): `https://plausible.io/js/script.js`
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### Konfiguration für Docker
|
||||
|
||||
Da es sich um **Build-Time Variablen** handelt (NEXT_PUBLIC_*), muss die App neu gebaut werden, wenn diese geändert werden.
|
||||
|
||||
#### Schritt 1: Umgebungsvariablen setzen
|
||||
|
||||
Erstelle oder bearbeite eine `.env`-Datei im Projektverzeichnis:
|
||||
|
||||
```bash
|
||||
# Plausible Analytics (Script-URL ist erforderlich)
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
|
||||
# Die Domain wird automatisch erkannt - keine weitere Konfiguration nötig!
|
||||
```
|
||||
|
||||
#### Schritt 2: docker-compose.yml konfigurieren
|
||||
|
||||
Stelle sicher, dass die Variablen als Build-Args übergeben werden:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hoerdle:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||
```
|
||||
|
||||
Die `docker-compose.example.yml` enthält bereits diese Konfiguration.
|
||||
|
||||
#### Schritt 3: App neu bauen
|
||||
|
||||
**WICHTIG:** Nach Änderung der Plausible-Variablen muss die App neu gebaut werden:
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Oder mit dem Deploy-Script:
|
||||
|
||||
```bash
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
### Konfiguration für beide Domains
|
||||
|
||||
Die App unterstützt **automatisches Tracking** für beide Domains (`hoerdle.de` und `hördle.de`). Die Domain wird automatisch aus dem Request-Header ausgelesen und entsprechend in Plausible getrackt.
|
||||
|
||||
#### Automatisches Domain-Tracking
|
||||
|
||||
**Standard-Verhalten:** Die App erkennt automatisch, welche Domain aufgerufen wurde, und setzt die entsprechende `data-domain` im Plausible-Script:
|
||||
- `https://hoerdle.de/*` → `data-domain="hoerdle.de"`
|
||||
- `https://hördle.de/*` → `data-domain="hördle.de"`
|
||||
|
||||
#### In Plausible konfigurieren
|
||||
|
||||
Du hast zwei Optionen:
|
||||
|
||||
##### Option 1: Beide Domains als separate Sites (separate Statistiken) - Empfohlen für getrenntes Tracking
|
||||
|
||||
1. Erstelle in Plausible zwei separate Sites:
|
||||
- `hoerdle.de`
|
||||
- `hördle.de`
|
||||
|
||||
2. Fertig! Die App trackt automatisch die richtige Domain.
|
||||
|
||||
**Vorteil:** Separate Statistiken für jede Domain.
|
||||
|
||||
##### Option 2: Beide Domains als Aliase für eine Site (gemeinsame Statistiken)
|
||||
|
||||
1. Erstelle in Plausible eine Site: `hoerdle.de`
|
||||
2. Füge `hördle.de` als Alias hinzu (in den Site-Einstellungen)
|
||||
|
||||
3. Fertig! Die App trackt automatisch die richtige Domain, und Plausible behandelt beide als Aliase für die gleiche Site.
|
||||
|
||||
**Hinweis:** Du musst nichts zusätzlich konfigurieren. Die App trackt automatisch `hoerdle.de` oder `hördle.de` basierend auf der Request-Domain, und Plausible erkennt beide als Aliase.
|
||||
|
||||
**Vorteil:** Gemeinsame Statistiken für beide Domains in einer Site.
|
||||
|
||||
#### Empfehlung
|
||||
|
||||
Für separate Statistiken: **Option 1** (automatisches Tracking)
|
||||
Für gemeinsame Statistiken: **Option 2** (Aliase in Plausible)
|
||||
|
||||
### Automatische CSP-Anpassung
|
||||
|
||||
Die Content Security Policy (CSP) in `proxy.ts` wird automatisch an die konfigurierte Plausible-URL angepasst. Die Domain wird automatisch aus der Script-URL extrahiert.
|
||||
|
||||
### Prüfen der Konfiguration
|
||||
|
||||
Nach dem Neubau kannst du prüfen, ob Plausible korrekt geladen wird:
|
||||
|
||||
1. **Browser-Entwicklertools öffnen**
|
||||
- Network-Tab: Suche nach dem Plausible-Script
|
||||
- Console: Prüfe auf Fehler
|
||||
|
||||
2. **Prüfe die Meta-Tags**
|
||||
```html
|
||||
<script defer data-domain="hoerdle.de" src="https://plausible.elpatron.me/js/script.js"></script>
|
||||
```
|
||||
|
||||
3. **Prüfe Plausible-Dashboard**
|
||||
- Öffne dein Plausible-Dashboard
|
||||
- Prüfe, ob Daten ankommen
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### Plausible wird nicht geladen
|
||||
|
||||
- Prüfe, ob die Umgebungsvariablen korrekt gesetzt sind
|
||||
- Prüfe, ob die App neu gebaut wurde (Build-Time Variablen!)
|
||||
- Prüfe Browser-Console auf CSP-Fehler
|
||||
|
||||
#### CSP blockiert Plausible
|
||||
|
||||
Die CSP sollte automatisch angepasst werden. Falls Probleme auftreten:
|
||||
- Prüfe, ob `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` korrekt gesetzt ist
|
||||
- Prüfe die Logs des Containers
|
||||
|
||||
#### Daten werden nicht in Plausible angezeigt
|
||||
|
||||
- Prüfe, ob die Domain in Plausible als Site konfiguriert ist
|
||||
- Prüfe, ob `data-domain` Attribut mit der konfigurierten Domain übereinstimmt
|
||||
- Prüfe Browser-Console auf Fehler beim Laden des Scripts
|
||||
|
||||
### Beispiel-Konfiguration
|
||||
|
||||
#### Für selbst gehostetes Plausible:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.elpatron.me/js/script.js
|
||||
```
|
||||
|
||||
#### Für Plausible.io (extern):
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC=https://plausible.io/js/script.js
|
||||
```
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt - keine weitere Konfiguration nötig!
|
||||
|
||||
### Weitere Informationen
|
||||
|
||||
- [Plausible Dokumentation](https://plausible.io/docs)
|
||||
- [Plausible Self-Hosting](https://plausible.io/docs/self-hosting)
|
||||
|
||||
293
docs/SCORING_OPTIONS.md
Normal file
293
docs/SCORING_OPTIONS.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Scoring-System Optionen
|
||||
|
||||
## Problem-Analyse
|
||||
|
||||
### Aktuelle Situation
|
||||
- **Start:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte (falscher Rateversuch) + -5 Punkte (Track-Verlängerung) = **-8 Punkte total**
|
||||
- **Skip:** -5 Punkte
|
||||
- **Replay:** -1 Punkt
|
||||
|
||||
### Problem (vor der Änderung)
|
||||
Bei vielen Versuchen kam man mit einem relativ hohen Score heraus:
|
||||
- Beispiel (alt): 7 Versuche = 90 + 20 - (6 × 3) = **92 Punkte**
|
||||
|
||||
### Lösung (aktuell implementiert)
|
||||
Bei falschen Rateversuchen werden zusätzlich -5 Punkte für die Track-Verlängerung (unlockSteps) abgezogen:
|
||||
- Beispiel (neu): 7 Versuche = 90 + 20 - (6 × 8) = **62 Punkte**
|
||||
- Start: 90 Punkte
|
||||
- 6 falsche Versuche: -48 Punkte (6 × -8, bestehend aus -3 für falsch + -5 für Verlängerung)
|
||||
- 1 richtiger Versuch: +20 Punkte
|
||||
- **Ergebnis: 62 Punkte**
|
||||
|
||||
Dies spiegelt nun besser die tatsächliche Leistung wider. Das System bleibt motivierend, da richtige Antworten weiterhin belohnt werden.
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Progressive Abzüge ⚠️ (Intransparent)
|
||||
|
||||
### Konzept
|
||||
Abzüge steigen mit jedem Versuch, aber das System ist schwer nachvollziehbar.
|
||||
|
||||
```
|
||||
- Versuch 1-2: -2 Punkte pro falscher Antwort
|
||||
- Versuch 3-4: -4 Punkte pro falscher Antwort
|
||||
- Versuch 5-6: -6 Punkte pro falscher Antwort
|
||||
- Versuch 7: -8 Punkte
|
||||
```
|
||||
|
||||
### Beispiel
|
||||
Bei 7 Versuchen: 90 + 20 - (2+2+4+4+6+6) = **86 Punkte**
|
||||
|
||||
### Probleme
|
||||
- **Intransparent**: Spieler müssen sich merken, welche Abzüge in welcher Runde gelten
|
||||
- **Schwer erklärbar**: Das Regelwerk ist komplex
|
||||
- **Unklar im UI**: Aktuelle Abzüge sind nicht sofort ersichtlich
|
||||
|
||||
### Vorteile
|
||||
- Progressive Bestrafung für viele Versuche
|
||||
- Fairer als aktuelles System
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Bonus-Malus-System
|
||||
|
||||
### Konzept
|
||||
Höhere Belohnungen für frühe Erfolge + progressive Abzüge.
|
||||
|
||||
```
|
||||
Start: 90 Punkte
|
||||
|
||||
Richtige Antwort (Bonus abhängig vom Versuch):
|
||||
- Versuch 1: +30 Punkte (sehr gut!)
|
||||
- Versuch 2: +25 Punkte (gut!)
|
||||
- Versuch 3: +20 Punkte (okay)
|
||||
- Versuch 4: +15 Punkte
|
||||
- Versuch 5+: +10 Punkte
|
||||
|
||||
Falsche Antwort (progressive Abzüge):
|
||||
- Versuch 1-2: -3 Punkte
|
||||
- Versuch 3-4: -5 Punkte
|
||||
- Versuch 5-6: -8 Punkte
|
||||
- Versuch 7: -10 Punkte
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 30 = **120 Punkte** ⭐
|
||||
- Gelöst in Versuch 4 (nach 3 Fehlern): 90 + 15 - (3+5+5) = **92 Punkte**
|
||||
- Gelöst in Versuch 7 (nach 6 Fehlern): 90 + 10 - (3+5+5+8+8+10) = **61 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Transparent**: Klare Regeln pro Versuch
|
||||
- **Motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Späte Erfolge werden abgewertet
|
||||
|
||||
### Nachteile
|
||||
- Etwas komplexer als aktuelles System
|
||||
- Muss im UI klar kommuniziert werden
|
||||
|
||||
---
|
||||
|
||||
## Option 3: Effizienz-Multiplikator
|
||||
|
||||
### Konzept
|
||||
Basis-System bleibt, aber Multiplikator basierend auf Versuchszahl.
|
||||
|
||||
```
|
||||
Basis-System (wie aktuell, aber mit höheren Abzügen):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
|
||||
Bonus-Multiplikatoren (basierend auf Versuch, in dem gelöst wurde):
|
||||
- Gelöst in 1-2 Versuchen: ×1.2 (20% Bonus)
|
||||
- Gelöst in 3-4 Versuchen: ×1.1 (10% Bonus)
|
||||
- Gelöst in 5-6 Versuchen: ×1.0 (kein Bonus)
|
||||
- Gelöst in 7 Versuchen: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 2 (1 Fehler): (90 + 20 - 5) × 1.2 = **126 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler): (90 + 20 - 15) × 1.1 = **104.5 → 105 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- Multiplikator ist einfach zu verstehen ("20% Bonus für schnelles Lösen")
|
||||
- Basis-System bleibt ähnlich
|
||||
- Gerechte Bestrafung für viele Versuche
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren müssen berechnet werden (könnte kompliziert wirken)
|
||||
- Kombination aus Basis + Multiplikator kann verwirrend sein
|
||||
|
||||
---
|
||||
|
||||
## Option 4: Kombiniertes System
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + kleine Motivations-Boni.
|
||||
|
||||
```
|
||||
Basis-System (höhere Abzüge):
|
||||
- Falsche Antwort: -5 Punkte (statt -3)
|
||||
- Skip: -7 Punkte (statt -5)
|
||||
- Richtige Antwort: +20 Punkte (bleibt)
|
||||
|
||||
Motivations-Boni:
|
||||
- "Erstversuch" Bonus: +2 Punkte wenn erster Versuch nicht skipped wurde
|
||||
- "Perfekter Durchlauf": +5 Bonus wenn kein Skip verwendet wurde
|
||||
- "Knapp daneben": +1 Punkt für Versuche, die fast richtig waren (optional, komplex)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: 90 + 20 + 2 + 5 = **117 Punkte**
|
||||
- Gelöst in Versuch 4 (3 Fehler, kein Skip): 90 + 20 - 15 + 5 = **100 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler, 2 Skips): 90 + 20 - 30 - 14 = **66 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Einfach verständlich**: Basis + kleine Boni
|
||||
- **Motivierend**: Positive Verstärkung für gutes Verhalten
|
||||
- **Fair**: Höhere Abzüge sorgen für differenzierten Score
|
||||
|
||||
### Nachteile
|
||||
- Mehrere kleine Boni können unübersichtlich werden
|
||||
- "Knapp daneben" ist schwer zu implementieren
|
||||
|
||||
---
|
||||
|
||||
## Option 5: Streak-System (Langfristige Motivation)
|
||||
|
||||
### Konzept
|
||||
Zusätzliche Belohnungen für konsequentes Spielen über mehrere Tage.
|
||||
|
||||
```
|
||||
Tägliche Streaks:
|
||||
- 3 Tage in Folge gelöst: +5 Bonus-Punkte
|
||||
- 7 Tage: +10 Bonus-Punkte
|
||||
- 30 Tage: +15 Bonus-Punkte
|
||||
```
|
||||
|
||||
**Kombiniert mit einem der anderen Systeme** (z.B. Option 2 oder 4).
|
||||
|
||||
### Vorteile
|
||||
- Langfristige Spielermotivation
|
||||
- Belohnt Engagement
|
||||
|
||||
### Nachteile
|
||||
- Braucht Tracking über mehrere Tage
|
||||
- Löst nicht das Hauptproblem (zu hoher Score bei vielen Versuchen)
|
||||
|
||||
---
|
||||
|
||||
## Option 6: Multiplikator-System (Vereinfacht)
|
||||
|
||||
### Konzept
|
||||
Höhere Abzüge + einfache Multiplikatoren für Versuchszahl.
|
||||
|
||||
```
|
||||
Höhere Basis-Abzüge:
|
||||
- Falsche Antwort: -5 Punkte
|
||||
- Skip: -7 Punkte
|
||||
|
||||
Multiplikator basierend auf Versuch, in dem gelöst wurde:
|
||||
- Versuch 1: ×1.5 (50% Bonus) → Sehr schnelles Lösen
|
||||
- Versuch 2: ×1.3 (30% Bonus)
|
||||
- Versuch 3: ×1.1 (10% Bonus)
|
||||
- Versuch 4: ×1.0 (kein Bonus/Aufschlag)
|
||||
- Versuch 5+: ×0.9 (10% Abzug)
|
||||
```
|
||||
|
||||
### Beispiele
|
||||
- Gelöst in Versuch 1: (90 + 20) × 1.5 = **165 Punkte** ⭐⭐⭐
|
||||
- Gelöst in Versuch 3 (2 Fehler): (90 + 20 - 10) × 1.1 = **110 Punkte**
|
||||
- Gelöst in Versuch 7 (6 Fehler): (90 + 20 - 30) × 0.9 = **72 Punkte**
|
||||
|
||||
### Vorteile
|
||||
- **Sehr transparent**: "50% Bonus für Erstversuch" ist einfach zu verstehen
|
||||
- **Stark motivierend**: Hohe Belohnungen für schnelles Lösen
|
||||
- **Fair**: Viele Versuche = niedriger Score
|
||||
|
||||
### Nachteile
|
||||
- Multiplikatoren könnten als zu komplex empfunden werden
|
||||
- Hohe Scores bei frühen Erfolgen (könnte als "zu leicht" empfunden werden)
|
||||
|
||||
---
|
||||
|
||||
## Empfehlungen
|
||||
|
||||
### Für Transparenz und Einfachheit: **Option 2 oder Option 4**
|
||||
|
||||
**Option 2 (Bonus-Malus)** ist am transparentesten:
|
||||
- Klare Werte pro Versuch
|
||||
- Einfach zu kommunizieren: "Erstversuch gibt +30, jeder weitere Versuch reduziert den Bonus"
|
||||
- Fair und motivierend
|
||||
|
||||
**Option 4 (Kombiniert)** ist am einfachsten:
|
||||
- Basis-System bleibt ähnlich (nur höhere Abzüge)
|
||||
- Zusätzliche kleine Boni sind optional und motivierend
|
||||
- Sehr einfach zu verstehen
|
||||
|
||||
### Für maximale Motivation: **Option 6**
|
||||
|
||||
- Hohe Belohnungen für schnelles Lösen
|
||||
- Einfache Multiplikatoren ("50% Bonus")
|
||||
- Sehr fair für viele Versuche
|
||||
|
||||
---
|
||||
|
||||
## Implementierungs-Hinweise
|
||||
|
||||
### UI-Kommunikation
|
||||
Welche Option auch gewählt wird - sie muss im Spiel klar kommuniziert werden:
|
||||
- Tooltips bei Versuchen
|
||||
- Score-Breakdown zeigt Abzüge/Boni pro Versuch
|
||||
- Vorschau: "Dieser Versuch würde X Punkte kosten/geben"
|
||||
|
||||
### Testing
|
||||
Vor der Implementierung sollten verschiedene Szenarien durchgespielt werden:
|
||||
- Erstversuch-Lösung
|
||||
- Mittlere Versuche (3-4)
|
||||
- Knappe Lösung (6-7 Versuche)
|
||||
- Mit/ohne Skips
|
||||
- Mit/ohne Replays
|
||||
|
||||
### Migration
|
||||
- Bestehende Scores können nicht einfach migriert werden
|
||||
- Neue Regeln gelten ab Start des neuen Systems
|
||||
- Eventuell: "New Scoring System" Ankündigung
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implementiert: Abzüge für zusätzliche Sekunden
|
||||
|
||||
**Status:** ✅ **Aktuell implementiert**
|
||||
|
||||
Bei falschen Rateversuchen werden zusätzlich **-5 Punkte für die Track-Verlängerung** abgezogen:
|
||||
- Falsche Antwort (Rateversuch): -3 Punkte (falsch) + -5 Punkte (Verlängerung) = **-8 Punkte total**
|
||||
- Skip: -5 Punkte (kein zusätzlicher Abzug, da Skip keine Verlängerung bedeutet)
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ Reflektiert den "Hilfsmittel"-Charakter der zusätzlichen Sekunden
|
||||
- ✅ Macht viele Versuche deutlich teurer
|
||||
- ✅ Fairer Score bei vielen Versuchen
|
||||
- ✅ Transparent: Klar getrennt als "Wrong guess" und "Track extension"
|
||||
|
||||
**Hinweis:** Dies ist die erste Anpassung des Scoring-Systems. Weitere Optionen (siehe oben) können in Zukunft ergänzt werden.
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
1. Sollen Replays weiterhin -1 Punkt kosten?
|
||||
2. Soll das Jahr-Bonus-System (+10) beibehalten werden?
|
||||
3. Wie wichtig ist Backward-Compatibility mit bestehenden Scores?
|
||||
4. Soll es eine "Preview"-Funktion geben ("Dieser Versuch kostet X Punkte")?
|
||||
5. Sollen zusätzlich freigeschaltete Sekunden (Unlock-Steps) zusätzlich Punkte kosten?
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
📝 **Erstellt:** 2024-12-01
|
||||
✅ **Erste Änderung implementiert:** 2024-12-01 - Track-Verlängerung kostet jetzt -5 Punkte bei falschen Rateversuchen
|
||||
🔄 **Status:** Teilweise umgesetzt
|
||||
💡 **Nächste Schritte:** Weitere Optionen können bei Bedarf ergänzt werden (siehe Optionen oben)
|
||||
|
||||
235
docs/SEO_TESTING.md
Normal file
235
docs/SEO_TESTING.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# SEO & Open Graph Testing Guide
|
||||
|
||||
## Übersicht
|
||||
|
||||
Diese Anleitung zeigt dir, wie du die SEO-Implementierung (Meta-Tags, Open Graph, Twitter Cards) testen kannst.
|
||||
|
||||
## Lokales Testen
|
||||
|
||||
### 1. Browser-Entwicklertools
|
||||
|
||||
1. **App starten:**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Die App läuft unter `http://localhost:3000`
|
||||
|
||||
2. **Meta-Tags im HTML prüfen:**
|
||||
- Öffne eine Seite (z.B. `http://localhost:3000/en` oder `http://localhost:3000/de/about`)
|
||||
- Rechtsklick → "Seite untersuchen" (F12)
|
||||
- Tab "Elements" → `<head>` Bereich erweitern
|
||||
- Suche nach Meta-Tags:
|
||||
- `<meta property="og:title">`
|
||||
- `<meta property="og:description">`
|
||||
- `<meta property="og:image">`
|
||||
- `<meta name="twitter:card">`
|
||||
|
||||
3. **View Page Source:**
|
||||
- Rechtsklick → "Seitenquelltext anzeigen"
|
||||
- Suche nach "og:" oder "twitter:" um alle Open Graph und Twitter Meta-Tags zu sehen
|
||||
|
||||
### 2. cURL-Test (für schnelle Prüfung)
|
||||
|
||||
```bash
|
||||
# Prüfe Meta-Tags einer Seite
|
||||
curl -s http://localhost:3000/en | grep -i "og:\|twitter:"
|
||||
```
|
||||
|
||||
### 3. Node.js-Script zum Testen
|
||||
|
||||
Erstelle eine Test-Datei `test-og.js`:
|
||||
|
||||
```javascript
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
function fetchHTML(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
client.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => resolve(data));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function testOGTags(url) {
|
||||
try {
|
||||
const html = await fetchHTML(url);
|
||||
const ogTags = {
|
||||
title: html.match(/<meta property="og:title" content="([^"]*)"/)?.[1],
|
||||
description: html.match(/<meta property="og:description" content="([^"]*)"/)?.[1],
|
||||
image: html.match(/<meta property="og:image" content="([^"]*)"/)?.[1],
|
||||
url: html.match(/<meta property="og:url" content="([^"]*)"/)?.[1],
|
||||
};
|
||||
|
||||
console.log('Open Graph Tags:', ogTags);
|
||||
return ogTags;
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
testOGTags('http://localhost:3000/en');
|
||||
```
|
||||
|
||||
## Online-Tools (für Produktions-URLs)
|
||||
|
||||
### 1. Facebook Sharing Debugger (Empfohlen)
|
||||
|
||||
**URL:** https://developers.facebook.com/tools/debug/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein (z.B. `https://hoerdle.de/en`)
|
||||
3. Klicke auf "Debuggen"
|
||||
4. Prüfe die Vorschau und alle Meta-Tags
|
||||
|
||||
**Wichtig:**
|
||||
- Facebook cached die Vorschau! Klicke auf "Scraping erneut ausführen" um den Cache zu leeren
|
||||
- Funktioniert nur mit öffentlich erreichbaren URLs (nicht localhost)
|
||||
|
||||
### 2. Twitter Card Validator
|
||||
|
||||
**URL:** https://cards-dev.twitter.com/validator
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die Twitter Card Vorschau
|
||||
|
||||
**Hinweis:** Twitter hat den Validator eingestellt, aber die Cards funktionieren trotzdem. Du kannst auch einfach einen Tweet mit deiner URL erstellen, um zu sehen, ob die Card angezeigt wird.
|
||||
|
||||
### 3. LinkedIn Post Inspector
|
||||
|
||||
**URL:** https://www.linkedin.com/post-inspector/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL (Login erforderlich)
|
||||
2. Gib deine Produktions-URL ein
|
||||
3. Prüfe die LinkedIn Vorschau
|
||||
|
||||
### 4. OpenGraph.xyz (Universelles Tool)
|
||||
|
||||
**URL:** https://www.opengraph.xyz/
|
||||
|
||||
**Verwendung:**
|
||||
1. Öffne die URL
|
||||
2. Gib deine URL ein
|
||||
3. Sieh dir alle Open Graph und Twitter Meta-Tags an
|
||||
4. Sieh dir die Vorschau für verschiedene Plattformen an
|
||||
|
||||
### 5. Metatags.io
|
||||
|
||||
**URL:** https://metatags.io/
|
||||
|
||||
**Verwendung:**
|
||||
- Gebe deine URL ein
|
||||
- Sieh dir alle Meta-Tags an
|
||||
- Vorschau für verschiedene Plattformen
|
||||
|
||||
## Produktions-Test (hoerdle.de / hördle.de)
|
||||
|
||||
Sobald die App deployed ist, kannst du alle oben genannten Tools mit deinen Produktions-URLs verwenden:
|
||||
|
||||
### Test-URLs:
|
||||
|
||||
- Homepage (EN): `https://hoerdle.de/en`
|
||||
- Homepage (DE): `https://hoerdle.de/de`
|
||||
- About (EN): `https://hoerdle.de/en/about`
|
||||
- About (DE): `https://hoerdle.de/de/about`
|
||||
- Genre-Seiten: `https://hoerdle.de/en/Rock` (Beispiel)
|
||||
- Special-Seiten: `https://hoerdle.de/en/special/Weihnachtslieder` (Beispiel)
|
||||
|
||||
### Schnelltest mit cURL:
|
||||
|
||||
```bash
|
||||
# Teste Homepage
|
||||
curl -s https://hoerdle.de/en | grep -E "og:|twitter:" | head -10
|
||||
|
||||
# Teste About-Seite
|
||||
curl -s https://hoerdle.de/de/about | grep -E "og:|twitter:" | head -10
|
||||
```
|
||||
|
||||
## Erwartete Meta-Tags
|
||||
|
||||
Die folgenden Meta-Tags sollten auf allen Seiten vorhanden sein:
|
||||
|
||||
### Open Graph Tags:
|
||||
- `og:title` - Seitentitel
|
||||
- `og:description` - Seitenbeschreibung
|
||||
- `og:image` - Bild für Social Media (Standard: `/favicon.ico`)
|
||||
- `og:url` - Canonical URL
|
||||
- `og:type` - Typ (sollte "website" sein)
|
||||
- `og:site_name` - Name der Site
|
||||
- `og:locale` - Sprache (de/en)
|
||||
|
||||
### Twitter Tags:
|
||||
- `twitter:card` - Card-Typ (sollte "summary_large_image" sein)
|
||||
- `twitter:title` - Titel
|
||||
- `twitter:description` - Beschreibung
|
||||
- `twitter:image` - Bild
|
||||
|
||||
### Canonical & Alternates:
|
||||
- `<link rel="canonical">` - Canonical URL
|
||||
- `<link rel="alternate" hreflang="de">` - Deutsche Version
|
||||
- `<link rel="alternate" hreflang="en">` - Englische Version
|
||||
- `<link rel="alternate" hreflang="x-default">` - Standard-Version
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Meta-Tags werden nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob die App läuft: `npm run dev`
|
||||
2. Prüfe Browser-Console auf Fehler
|
||||
3. Stelle sicher, dass `generateMetadata` in der Seite exportiert ist
|
||||
4. Prüfe, ob `lib/metadata.ts` korrekt importiert wird
|
||||
|
||||
### Problem: Open Graph Image wird nicht angezeigt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob das Bild unter `/favicon.ico` existiert (oder konfiguriertes OG-Image)
|
||||
2. Für bessere Ergebnisse: Erstelle ein dediziertes Open Graph Bild (1200x630px)
|
||||
3. Platziere es in `public/og-image.png`
|
||||
4. Setze in `.env`: `NEXT_PUBLIC_OG_IMAGE=/og-image.png`
|
||||
|
||||
### Problem: Facebook zeigt alte Vorschau
|
||||
|
||||
**Lösung:**
|
||||
1. Öffne Facebook Sharing Debugger
|
||||
2. Gib deine URL ein
|
||||
3. Klicke auf "Scraping erneut ausführen" (mehrfach, falls nötig)
|
||||
4. Facebook cached die Vorschau - Cache kann mehrere Stunden dauern
|
||||
|
||||
### Problem: Domain-Erkennung funktioniert nicht
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe `lib/seo.ts` - `getBaseUrl()` Funktion
|
||||
2. Stelle sicher, dass Request-Headers korrekt sind
|
||||
3. In Produktion: Prüfe, ob Proxy-Headers (`x-forwarded-host`) korrekt gesetzt sind
|
||||
|
||||
## Open Graph Bild optimieren
|
||||
|
||||
Für bessere Social Media Vorschauen solltest du ein dediziertes OG-Bild erstellen:
|
||||
|
||||
**Empfohlene Größe:** 1200x630px
|
||||
**Format:** PNG oder JPG
|
||||
**Pfad:** `public/og-image.png`
|
||||
|
||||
**Konfiguration:**
|
||||
```bash
|
||||
# In .env
|
||||
NEXT_PUBLIC_OG_IMAGE=/og-image.png
|
||||
```
|
||||
|
||||
Dann wird dieses Bild in allen Open Graph Meta-Tags verwendet.
|
||||
|
||||
## Nützliche Links
|
||||
|
||||
- [Open Graph Protocol Dokumentation](https://ogp.me/)
|
||||
- [Twitter Cards Dokumentation](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
- [Facebook Sharing Best Practices](https://developers.facebook.com/docs/sharing/webmasters)
|
||||
|
||||
206
docs/TROUBLESHOOTING.md
Normal file
206
docs/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
docs/WHITE_LABEL.md
Normal file
99
docs/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.de` |
|
||||
|
||||
### Analytics (Plausible)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | The URL of the Plausible script. | `https://plausible.example.com/js/script.js` |
|
||||
|
||||
**Hinweis:** Die Domain wird automatisch aus der Request-Domain erkannt. Beide Domains (`hoerdle.de` und `hördle.de`) werden automatisch getrackt.
|
||||
|
||||
### 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
|
||||
```
|
||||
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 = 'en';
|
||||
console.log('[i18n/request] falling back to default locale:', locale);
|
||||
}
|
||||
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default
|
||||
};
|
||||
});
|
||||
58
lib/auth.ts
58
lib/auth.ts
@@ -1,4 +1,11 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PrismaClient, Curator } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export type StaffContext =
|
||||
| { role: 'admin' }
|
||||
| { role: 'curator'; curator: Curator };
|
||||
|
||||
/**
|
||||
* Authentication middleware for admin API routes
|
||||
@@ -17,6 +24,57 @@ export async function requireAdminAuth(request: NextRequest): Promise<NextRespon
|
||||
return null; // Auth successful
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve current staff (admin or curator) from headers.
|
||||
*
|
||||
* Admin:
|
||||
* - x-admin-auth: 'authenticated'
|
||||
*
|
||||
* Curator:
|
||||
* - x-curator-auth: 'authenticated'
|
||||
* - x-curator-username: <username>
|
||||
*/
|
||||
export async function getStaffContext(request: NextRequest): Promise<StaffContext | null> {
|
||||
const adminHeader = request.headers.get('x-admin-auth');
|
||||
if (adminHeader === 'authenticated') {
|
||||
return { role: 'admin' };
|
||||
}
|
||||
|
||||
const curatorAuth = request.headers.get('x-curator-auth');
|
||||
const curatorUsername = request.headers.get('x-curator-username');
|
||||
|
||||
if (curatorAuth === 'authenticated' && curatorUsername) {
|
||||
const curator = await prisma.curator.findUnique({
|
||||
where: { username: curatorUsername },
|
||||
});
|
||||
|
||||
if (curator) {
|
||||
return { role: 'curator', curator };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the current request is authenticated as staff (admin or curator).
|
||||
* Returns either an error response or a resolved context.
|
||||
*/
|
||||
export async function requireStaffAuth(request: NextRequest): Promise<{ error?: NextResponse; context?: StaffContext }> {
|
||||
const context = await getStaffContext(request);
|
||||
|
||||
if (!context) {
|
||||
return {
|
||||
error: NextResponse.json(
|
||||
{ error: 'Unauthorized - Staff authentication required' },
|
||||
{ status: 401 }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { context };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to verify admin password
|
||||
*/
|
||||
|
||||
20
lib/config.ts
Normal file
20
lib/config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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.de',
|
||||
plausibleScriptSrc: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/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 || 'Made 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',
|
||||
},
|
||||
seo: {
|
||||
ogImage: process.env.NEXT_PUBLIC_OG_IMAGE || '/api/og-image',
|
||||
twitterHandle: process.env.NEXT_PUBLIC_TWITTER_HANDLE || undefined,
|
||||
}
|
||||
};
|
||||
@@ -1,22 +1,15 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Genre, Special } from '@prisma/client';
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
|
||||
try {
|
||||
const today = getTodayISOString();
|
||||
let genreId: number | null = null;
|
||||
|
||||
if (genreName) {
|
||||
const genre = await prisma.genre.findUnique({
|
||||
where: { name: genreName }
|
||||
});
|
||||
if (genre) {
|
||||
genreId = genre.id;
|
||||
} else {
|
||||
return null; // Genre not found
|
||||
}
|
||||
}
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
@@ -27,8 +20,6 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
include: { song: true },
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (!dailyPuzzle) {
|
||||
// Get songs available for this genre
|
||||
const whereClause = genreId
|
||||
@@ -45,7 +36,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -80,7 +71,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
},
|
||||
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) {
|
||||
// Handle 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,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
releaseYear: dailyPuzzle.song.releaseYear,
|
||||
genre: genreName
|
||||
genre: genre ? genre.name : null
|
||||
};
|
||||
|
||||
} 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 {
|
||||
const today = getTodayISOString();
|
||||
|
||||
const special = await prisma.special.findUnique({
|
||||
where: { name: specialName }
|
||||
});
|
||||
|
||||
if (!special) return null;
|
||||
|
||||
let dailyPuzzle = await prisma.dailyPuzzle.findFirst({
|
||||
where: {
|
||||
date: today,
|
||||
@@ -232,7 +217,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
releaseYear: dailyPuzzle.song.releaseYear,
|
||||
special: specialName,
|
||||
special: special.name,
|
||||
maxAttempts: special.maxAttempts,
|
||||
unlockSteps: JSON.parse(special.unlockSteps),
|
||||
startTime: specialSong?.startTime || 0
|
||||
|
||||
47
lib/externalPuzzles.ts
Normal file
47
lib/externalPuzzles.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type ExternalPuzzle = {
|
||||
id: string;
|
||||
nameDe: string;
|
||||
nameEn: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Zentrale Liste externer Rätselangebote.
|
||||
*
|
||||
* Erweiterung: Einfach neuen Eintrag in dieses Array hinzufügen.
|
||||
*/
|
||||
export const externalPuzzles: ExternalPuzzle[] = [
|
||||
{
|
||||
id: 'pastpuzzle',
|
||||
nameDe: 'Past Puzzle',
|
||||
nameEn: 'Past Puzzle',
|
||||
url: 'https://www.pastpuzzle.de/#/',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'woerdle',
|
||||
nameDe: 'Wördle',
|
||||
nameEn: 'Wördle',
|
||||
url: 'https://www.wördle.de',
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'ciddle',
|
||||
nameDe: 'Ciddle',
|
||||
nameEn: 'Ciddle',
|
||||
url: 'https://ciddle.winklerweb.net',
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getRandomExternalPuzzle(): ExternalPuzzle | null {
|
||||
const activePuzzles = externalPuzzles.filter(p => p.isActive !== false);
|
||||
if (activePuzzles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.floor(Math.random() * activePuzzles.length);
|
||||
return activePuzzles[index] ?? null;
|
||||
}
|
||||
|
||||
|
||||
68
lib/extraPuzzlesTracker.ts
Normal file
68
lib/extraPuzzlesTracker.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
|
||||
const DAILY_PLAYED_PREFIX = 'hoerdle_daily_played_';
|
||||
const EXTRA_POPOVER_PREFIX = 'hoerdle_extra_puzzles_shown_';
|
||||
|
||||
function getTodayKey(prefix: string): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const today = getTodayISOString();
|
||||
return `${prefix}${today}`;
|
||||
}
|
||||
|
||||
export function markDailyPuzzlePlayedToday(genreKey: string) {
|
||||
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
const list: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!list.includes(genreKey)) {
|
||||
list.push(genreKey);
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(list));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to mark daily puzzle as played', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPlayedAllDailyPuzzlesForToday(requiredGenreKeys: string[]): boolean {
|
||||
const storageKey = getTodayKey(DAILY_PLAYED_PREFIX);
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
const played: string[] = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(played) || played.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return requiredGenreKeys.every(key => played.includes(key));
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to read played puzzles', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSeenExtraPuzzlesPopoverToday(): boolean {
|
||||
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === 'true';
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to read popover state', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function markExtraPuzzlesPopoverShownToday() {
|
||||
const storageKey = getTodayKey(EXTRA_POPOVER_PREFIX);
|
||||
if (!storageKey) return;
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, 'true');
|
||||
} catch (e) {
|
||||
console.warn('[extraPuzzles] Failed to persist popover state', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
lib/gameState.ts
163
lib/gameState.ts
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getTodayISOString } from './dateUtils';
|
||||
import { loadPlayerState, savePlayerState, getGenreKey } from './playerStorage';
|
||||
|
||||
export interface GameState {
|
||||
date: string;
|
||||
@@ -27,17 +28,19 @@ export interface Statistics {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||
|
||||
const INITIAL_SCORE = 90;
|
||||
|
||||
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
||||
export function useGameState(
|
||||
genre: string | null = null,
|
||||
maxAttempts: number = 7,
|
||||
isSpecial: boolean = false
|
||||
) {
|
||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
|
||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
||||
// Get genre key for backend storage
|
||||
// For specials, genre contains the special name
|
||||
const genreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
const createNewState = (date: string): GameState => ({
|
||||
date,
|
||||
@@ -52,52 +55,7 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
yearGuessed: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Load game state
|
||||
const storageKey = getStorageKey();
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
const today = getTodayISOString();
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.date === today) {
|
||||
// Migration for existing states without score
|
||||
if (parsed.score === undefined) {
|
||||
parsed.score = INITIAL_SCORE;
|
||||
parsed.replayCount = 0;
|
||||
parsed.skipCount = 0;
|
||||
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
||||
parsed.yearGuessed = false;
|
||||
|
||||
// Retroactively deduct points for existing guesses if possible,
|
||||
// but simpler to just start at 90 for active games to avoid confusion
|
||||
}
|
||||
setGameState(parsed as GameState);
|
||||
} else {
|
||||
// New day
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
} else {
|
||||
// No state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
|
||||
// Load statistics
|
||||
const statsKey = getStatsKey();
|
||||
const storedStats = localStorage.getItem(statsKey);
|
||||
if (storedStats) {
|
||||
const parsedStats = JSON.parse(storedStats);
|
||||
// Migration for existing stats without solvedIn7
|
||||
if (parsedStats.solvedIn7 === undefined) {
|
||||
parsedStats.solvedIn7 = 0;
|
||||
}
|
||||
setStatistics(parsedStats);
|
||||
} else {
|
||||
const newStats: Statistics = {
|
||||
const createNewStatistics = (): Statistics => ({
|
||||
solvedIn1: 0,
|
||||
solvedIn2: 0,
|
||||
solvedIn3: 0,
|
||||
@@ -106,18 +64,85 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
solvedIn6: 0,
|
||||
solvedIn7: 0,
|
||||
failed: 0,
|
||||
};
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(statsKey, JSON.stringify(newStats));
|
||||
}
|
||||
}, [genre]); // Re-run when genre changes
|
||||
});
|
||||
|
||||
const saveState = (newState: GameState) => {
|
||||
useEffect(() => {
|
||||
const today = getTodayISOString();
|
||||
|
||||
// Always recompute genreKey to ensure it's current
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
|
||||
// Try to load from backend first
|
||||
const loadFromBackend = async () => {
|
||||
try {
|
||||
const backendState = await loadPlayerState(currentGenreKey);
|
||||
|
||||
if (backendState) {
|
||||
const { gameState: loadedState, statistics: loadedStats } = backendState;
|
||||
|
||||
// Check if the loaded state is for today
|
||||
if (loadedState.date === today) {
|
||||
setGameState(loadedState);
|
||||
setStatistics(loadedStats);
|
||||
return; // Successfully loaded from backend
|
||||
} else {
|
||||
// State is for a different day - create new state
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(getStorageKey(), JSON.stringify(newState));
|
||||
setStatistics(loadedStats); // Keep statistics across days
|
||||
// Save new state to backend
|
||||
await savePlayerState(currentGenreKey, newState, loadedStats);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No backend state found - create new state
|
||||
// This is the normal case for first-time players or new genre
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Save to backend for cross-domain sync
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to load from backend:', error);
|
||||
|
||||
// On error, create new state and try to save to backend
|
||||
// This handles network errors gracefully
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
const newStats = createNewStatistics();
|
||||
setStatistics(newStats);
|
||||
// Try to save to backend (may fail, but we try)
|
||||
try {
|
||||
await savePlayerState(currentGenreKey, newState, newStats);
|
||||
} catch (saveError) {
|
||||
console.error('[gameState] Failed to save new state to backend:', saveError);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatistics = (attempts: number, solved: boolean) => {
|
||||
loadFromBackend();
|
||||
}, [genre, isSpecial]); // Re-run when genre or isSpecial changes
|
||||
|
||||
const saveState = async (newState: GameState) => {
|
||||
setGameState(newState);
|
||||
|
||||
// Save to backend only
|
||||
if (statistics) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, newState, statistics);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatistics = async (attempts: number, solved: boolean) => {
|
||||
if (!statistics) return;
|
||||
|
||||
const newStats = { ...statistics };
|
||||
@@ -139,11 +164,24 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
}
|
||||
|
||||
setStatistics(newStats);
|
||||
localStorage.setItem(getStatsKey(), JSON.stringify(newStats));
|
||||
|
||||
// Save to backend only
|
||||
if (gameState) {
|
||||
try {
|
||||
// Always use the current genreKey (recompute it in case genre/isSpecial changed)
|
||||
const currentGenreKey = getGenreKey(isSpecial ? null : genre, isSpecial, isSpecial ? genre || undefined : undefined);
|
||||
await savePlayerState(currentGenreKey, gameState, newStats);
|
||||
} catch (error) {
|
||||
console.error('[gameState] Failed to save statistics to backend:', error);
|
||||
// No fallback - backend is required for cross-domain sync
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addGuess = (guess: string, correct: boolean) => {
|
||||
if (!gameState) return;
|
||||
// Prevent adding guesses if already solved or failed
|
||||
if (gameState.isSolved || gameState.isFailed) return;
|
||||
|
||||
const newGuesses = [...gameState.guesses, guess];
|
||||
const isSolved = correct;
|
||||
@@ -162,6 +200,9 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
} else {
|
||||
newScore -= 3;
|
||||
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
||||
// Additional penalty for track extension (unlock steps)
|
||||
newScore -= 5;
|
||||
newBreakdown.push({ value: -5, reason: 'Track extension' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 'en'
|
||||
if (value['en']) return value['en'];
|
||||
|
||||
// Fallback to 'de'
|
||||
if (value['de']) return value['de'];
|
||||
|
||||
// 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()
|
||||
};
|
||||
}
|
||||
64
lib/metadata.ts
Normal file
64
lib/metadata.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { config } from './config';
|
||||
import { getBaseUrl } from './seo';
|
||||
|
||||
/**
|
||||
* Generate base metadata with Open Graph, Twitter Cards, and canonical URLs
|
||||
*/
|
||||
export async function generateBaseMetadata(
|
||||
locale: string,
|
||||
path: string = '',
|
||||
title?: string,
|
||||
description?: string,
|
||||
image?: string
|
||||
): Promise<Metadata> {
|
||||
const baseUrl = await getBaseUrl();
|
||||
const pathSegment = path ? `/${path}` : '';
|
||||
const fullUrl = `${baseUrl}/${locale}${pathSegment}`;
|
||||
|
||||
// Determine alternate URLs for both locales (same path for both)
|
||||
const alternateLocale = locale === 'de' ? 'en' : 'de';
|
||||
const alternateUrl = `${baseUrl}/${alternateLocale}${pathSegment}`;
|
||||
|
||||
// Default values
|
||||
const metaTitle = title || config.appName;
|
||||
const metaDescription = description || config.appDescription;
|
||||
const ogImage = image || `${baseUrl}${config.seo.ogImage}`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
alternates: {
|
||||
canonical: fullUrl,
|
||||
languages: {
|
||||
[locale]: fullUrl,
|
||||
[alternateLocale]: alternateUrl,
|
||||
'x-default': `${baseUrl}/en${pathSegment}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
url: fullUrl,
|
||||
siteName: config.appName,
|
||||
images: [
|
||||
{
|
||||
url: ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: metaTitle,
|
||||
},
|
||||
],
|
||||
locale: locale,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
images: [ogImage],
|
||||
...(config.seo.twitterHandle && { creator: config.seo.twitterHandle }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
223
lib/playerId.ts
Normal file
223
lib/playerId.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Player Identifier Management
|
||||
*
|
||||
* Generates and manages a unique player identifier (UUID) that is stored
|
||||
* in localStorage. This identifier is used to sync game states across
|
||||
* different domains (hoerdle.de and hördle.de).
|
||||
*
|
||||
* Device-specific isolation:
|
||||
* - Each device has its own device ID stored in localStorage
|
||||
* - Player ID format: {basePlayerId}:{deviceId}
|
||||
* - This allows cross-domain sync on the same device while keeping devices isolated
|
||||
*/
|
||||
|
||||
const STORAGE_KEY_PLAYER = 'hoerdle_player_id';
|
||||
const STORAGE_KEY_DEVICE = 'hoerdle_device_id';
|
||||
|
||||
/**
|
||||
* Generate a UUID v4
|
||||
*/
|
||||
function generateUUID(): string {
|
||||
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a device ID (unique per device)
|
||||
*
|
||||
* The device ID is stored in localStorage and persists across sessions.
|
||||
* This allows device-specific isolation of game states.
|
||||
*
|
||||
* @returns Device identifier (UUID v4)
|
||||
*/
|
||||
export function getOrCreateDeviceId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let deviceId = localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||
if (!deviceId) {
|
||||
deviceId = generateUUID();
|
||||
localStorage.setItem(STORAGE_KEY_DEVICE, deviceId);
|
||||
}
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device ID without creating a new one
|
||||
*
|
||||
* @returns Device identifier or null if not set
|
||||
*/
|
||||
export function getDeviceId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY_DEVICE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a base player ID (for cross-domain sync)
|
||||
*/
|
||||
function generateBasePlayerId(): string {
|
||||
return generateUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an existing base player ID from the backend
|
||||
*
|
||||
* Extracts the base player ID from a full player ID (format: {basePlayerId}:{deviceId})
|
||||
*
|
||||
* @param genreKey - Genre key to search for
|
||||
* @returns Base player ID if found, null otherwise
|
||||
*/
|
||||
async function findExistingBasePlayerId(genreKey: string): Promise<string | null> {
|
||||
try {
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
const response = await fetch('/api/player-id/suggest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ genreKey, deviceId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.basePlayerId) {
|
||||
return data.basePlayerId;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[playerId] Failed to find existing base player ID:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine base player ID and device ID into full player ID
|
||||
* Format: {basePlayerId}:{deviceId}
|
||||
*/
|
||||
function combinePlayerId(basePlayerId: string, deviceId: string): string {
|
||||
return `${basePlayerId}:${deviceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract base player ID from full player ID
|
||||
* Format: {basePlayerId}:{deviceId} -> {basePlayerId}
|
||||
*/
|
||||
function extractBasePlayerId(fullPlayerId: string): string {
|
||||
const colonIndex = fullPlayerId.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// Legacy format (no device ID) - return as is
|
||||
return fullPlayerId;
|
||||
}
|
||||
return fullPlayerId.substring(0, colonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a player identifier
|
||||
*
|
||||
* Player ID format: {basePlayerId}:{deviceId}
|
||||
*
|
||||
* If no identifier exists in localStorage, tries to find an existing base player ID
|
||||
* from the backend (for cross-domain sync). If none found, generates a new base ID.
|
||||
* The device ID is always device-specific.
|
||||
*
|
||||
* This enables:
|
||||
* - Cross-domain synchronization on the same device (same base player ID)
|
||||
* - Device isolation (different device IDs)
|
||||
*
|
||||
* @param genreKey - Optional genre key to search for existing base player ID
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||
*/
|
||||
export async function getOrCreatePlayerIdAsync(genreKey?: string): Promise<string> {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Always get/create device ID (device-specific)
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
|
||||
// Try to get base player ID from localStorage
|
||||
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!basePlayerId) {
|
||||
// Try to find an existing base player ID from backend if genreKey is provided
|
||||
if (genreKey) {
|
||||
const existingBaseId = await findExistingBasePlayerId(genreKey);
|
||||
if (existingBaseId) {
|
||||
basePlayerId = existingBaseId;
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new base player ID if no existing one found
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine base player ID with device ID
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a player identifier (synchronous version)
|
||||
*
|
||||
* This is the legacy synchronous version. For cross-domain sync, use getOrCreatePlayerIdAsync instead.
|
||||
*
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId})
|
||||
*/
|
||||
export function getOrCreatePlayerId(): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const deviceId = getOrCreateDeviceId();
|
||||
let basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!basePlayerId) {
|
||||
basePlayerId = generateBasePlayerId();
|
||||
localStorage.setItem(STORAGE_KEY_PLAYER, basePlayerId);
|
||||
}
|
||||
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current player identifier without creating a new one
|
||||
*
|
||||
* @returns Full player identifier ({basePlayerId}:{deviceId}) or null if not set
|
||||
*/
|
||||
export function getPlayerId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deviceId = getDeviceId();
|
||||
const basePlayerId = localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
|
||||
if (!deviceId || !basePlayerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return combinePlayerId(basePlayerId, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base player ID (for debugging/logging)
|
||||
*
|
||||
* @returns Base player ID or null if not set
|
||||
*/
|
||||
export function getBasePlayerId(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(STORAGE_KEY_PLAYER);
|
||||
}
|
||||
|
||||
134
lib/playerStorage.ts
Normal file
134
lib/playerStorage.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Player Storage API
|
||||
*
|
||||
* Handles loading and saving player game states from/to the backend.
|
||||
* This enables cross-domain synchronization between hoerdle.de and hördle.de.
|
||||
*/
|
||||
|
||||
import { getOrCreatePlayerId } from './playerId';
|
||||
import type { GameState, Statistics } from './gameState';
|
||||
|
||||
/**
|
||||
* Get genre key for storage
|
||||
*
|
||||
* Formats the genre/special into a consistent key format:
|
||||
* - Global: "global"
|
||||
* - Genre: "Rock" (localized name)
|
||||
* - Special: "special:00725" (special name)
|
||||
*/
|
||||
export function getGenreKey(
|
||||
genre: string | null,
|
||||
isSpecial: boolean,
|
||||
specialName?: string
|
||||
): string {
|
||||
if (isSpecial && specialName) {
|
||||
return `special:${specialName}`;
|
||||
}
|
||||
return genre || 'global';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load player state from backend
|
||||
*
|
||||
* @param genreKey - Genre key (from getGenreKey)
|
||||
* @returns GameState and Statistics, or null if not found
|
||||
*/
|
||||
export async function loadPlayerState(
|
||||
genreKey: string
|
||||
): Promise<{ gameState: GameState; statistics: Statistics } | null> {
|
||||
try {
|
||||
// Use async version to enable cross-domain player ID sync
|
||||
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||
const playerId = await getOrCreatePlayerIdAsync(genreKey);
|
||||
if (!playerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if it's a special or genre
|
||||
const isSpecial = genreKey.startsWith('special:');
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (isSpecial) {
|
||||
const specialName = genreKey.replace('special:', '');
|
||||
params.append('special', specialName);
|
||||
} else if (genreKey !== 'global') {
|
||||
params.append('genre', genreKey);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/player-state?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Player-Id': playerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// No state found - this is normal for new players
|
||||
return null;
|
||||
}
|
||||
// Other errors: log and return null (will fallback to localStorage)
|
||||
console.warn('[playerStorage] Failed to load player state:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.gameState || !data.statistics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
gameState: data.gameState as GameState,
|
||||
statistics: data.statistics as Statistics,
|
||||
};
|
||||
} catch (error) {
|
||||
// Network errors or other issues: fallback to localStorage
|
||||
console.warn('[playerStorage] Error loading player state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save player state to backend
|
||||
*
|
||||
* @param genreKey - Genre key (from getGenreKey)
|
||||
* @param gameState - Current game state
|
||||
* @param statistics - Current statistics
|
||||
*/
|
||||
export async function savePlayerState(
|
||||
genreKey: string,
|
||||
gameState: GameState,
|
||||
statistics: Statistics
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Use async version to ensure device ID is included
|
||||
const { getOrCreatePlayerIdAsync } = await import('./playerId');
|
||||
const playerId = await getOrCreatePlayerIdAsync();
|
||||
if (!playerId) {
|
||||
console.warn('[playerStorage] No player ID available, cannot save state');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/player-state', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Player-Id': playerId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
genreKey,
|
||||
gameState,
|
||||
statistics,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[playerStorage] Failed to save player state:', response.status);
|
||||
// Don't throw - allow fallback to localStorage
|
||||
}
|
||||
} catch (error) {
|
||||
// Network errors: log but don't throw (will fallback to localStorage)
|
||||
console.warn('[playerStorage] Error saving player state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
94
lib/politicalStatements.ts
Normal file
94
lib/politicalStatements.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { PrismaClient, PoliticalStatement as PrismaPoliticalStatement } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export type PoliticalStatement = {
|
||||
id: number;
|
||||
locale: string;
|
||||
text: string;
|
||||
active: boolean;
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
function mapFromPrisma(stmt: PrismaPoliticalStatement): PoliticalStatement {
|
||||
return {
|
||||
id: stmt.id,
|
||||
locale: stmt.locale,
|
||||
text: stmt.text,
|
||||
active: stmt.active,
|
||||
source: stmt.source,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRandomActiveStatement(locale: string): Promise<PoliticalStatement | null> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
const all = await prisma.politicalStatement.findMany({
|
||||
where: {
|
||||
locale: safeLocale,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (all.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = Math.floor(Math.random() * all.length);
|
||||
return mapFromPrisma(all[index]);
|
||||
}
|
||||
|
||||
export async function getAllStatements(locale: string): Promise<PoliticalStatement[]> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
const all = await prisma.politicalStatement.findMany({
|
||||
where: { locale: safeLocale },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return all.map(mapFromPrisma);
|
||||
}
|
||||
|
||||
export async function createStatement(locale: string, input: Omit<PoliticalStatement, 'id' | 'locale'>): Promise<PoliticalStatement> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
const created = await prisma.politicalStatement.create({
|
||||
data: {
|
||||
locale: safeLocale,
|
||||
text: input.text,
|
||||
active: input.active ?? true,
|
||||
source: input.source ?? null,
|
||||
},
|
||||
});
|
||||
return mapFromPrisma(created);
|
||||
}
|
||||
|
||||
export async function updateStatement(locale: string, id: number, input: Partial<Omit<PoliticalStatement, 'id' | 'locale'>>): Promise<PoliticalStatement | null> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
|
||||
// Optional: sicherstellen, dass das Statement zu dieser Locale gehört
|
||||
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||
if (!existing || existing.locale !== safeLocale) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated = await prisma.politicalStatement.update({
|
||||
where: { id },
|
||||
data: {
|
||||
text: input.text ?? existing.text,
|
||||
active: input.active ?? existing.active,
|
||||
source: input.source !== undefined ? input.source : existing.source,
|
||||
},
|
||||
});
|
||||
|
||||
return mapFromPrisma(updated);
|
||||
}
|
||||
|
||||
export async function deleteStatement(locale: string, id: number): Promise<boolean> {
|
||||
const safeLocale = ['de', 'en'].includes(locale) ? locale : 'en';
|
||||
|
||||
const existing = await prisma.politicalStatement.findUnique({ where: { id } });
|
||||
if (!existing || existing.locale !== safeLocale) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await prisma.politicalStatement.delete({ where: { id } });
|
||||
return true;
|
||||
}
|
||||
|
||||
43
lib/seo.ts
Normal file
43
lib/seo.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { config } from './config';
|
||||
|
||||
/**
|
||||
* Get the current base URL from request headers
|
||||
* Automatically detects hoerdle.de or hördle.de (xn--hrdle-jua.de)
|
||||
*/
|
||||
export async function getBaseUrl(): Promise<string> {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get('host') || headersList.get('x-forwarded-host') || '';
|
||||
|
||||
let domain = config.domain; // Default fallback
|
||||
|
||||
if (host) {
|
||||
// Extract domain from host (remove port if present)
|
||||
const detectedDomain = host.split(':')[0].toLowerCase();
|
||||
|
||||
// Map domains
|
||||
if (detectedDomain === 'hoerdle.de') {
|
||||
domain = 'hoerdle.de';
|
||||
} else if (detectedDomain === 'hördle.de' || detectedDomain === 'xn--hrdle-jua.de') {
|
||||
domain = 'hördle.de';
|
||||
} else {
|
||||
// Use detected domain if it's different from default
|
||||
domain = detectedDomain;
|
||||
}
|
||||
}
|
||||
|
||||
// Always use HTTPS in production
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
return `${protocol}://${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL synchronously (for use in non-async contexts)
|
||||
* Uses environment variable or config as fallback
|
||||
*/
|
||||
export function getBaseUrlSync(): string {
|
||||
const domain = process.env.NEXT_PUBLIC_DOMAIN || config.domain;
|
||||
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http';
|
||||
return `${protocol}://${domain}`;
|
||||
}
|
||||
|
||||
283
messages/de.json
Normal file
283
messages/de.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"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",
|
||||
"shareExplanation": "Teile dein Ergebnis mit Freund:innen – so hilfst du, Hördle bekannter zu machen.",
|
||||
"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:",
|
||||
"ratingTooltip": "Hilf unseren Kuratoren, gute Rätsel zu machen!",
|
||||
"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"
|
||||
},
|
||||
"ExtraPuzzles": {
|
||||
"title": "Noch nicht genug Rätsel?",
|
||||
"message": "Hey, hast du Lust auf weitere Rätsel? Dann schau doch mal bei {name} vorbei!",
|
||||
"cta": "Zu {name}",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"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": "Kuratieren",
|
||||
"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",
|
||||
"manageCurators": "Kuratoren verwalten",
|
||||
"addCurator": "Kurator hinzufügen",
|
||||
"curatorUsername": "Benutzername",
|
||||
"curatorPassword": "Passwort (bei Leer lassen: nicht ändern)",
|
||||
"isGlobalCurator": "Globaler Kurator (darf Global-Flag ändern)",
|
||||
"assignedGenres": "Zugeordnete Genres",
|
||||
"assignedSpecials": "Zugeordnete Specials",
|
||||
"noCurators": "Noch keine Kuratoren angelegt."
|
||||
},
|
||||
"Curator": {
|
||||
"loginTitle": "Kuratoren-Login",
|
||||
"loginUsername": "Benutzername",
|
||||
"loginPassword": "Passwort",
|
||||
"loginButton": "Einloggen",
|
||||
"logout": "Abmelden",
|
||||
"loginFailed": "Login fehlgeschlagen.",
|
||||
"loginNetworkError": "Netzwerkfehler beim Login.",
|
||||
"loadCuratorError": "Fehler beim Laden der Kuratoren-Informationen.",
|
||||
"loadSongsError": "Fehler beim Laden der Songs.",
|
||||
"songUpdated": "Song erfolgreich aktualisiert.",
|
||||
"saveError": "Fehler beim Speichern: {error}",
|
||||
"saveNetworkError": "Netzwerkfehler beim Speichern.",
|
||||
"noDeletePermission": "Du darfst diesen Song nicht löschen.",
|
||||
"deleteConfirm": "Möchtest du \"{title}\" wirklich löschen?",
|
||||
"songDeleted": "Song gelöscht.",
|
||||
"deleteError": "Fehler beim Löschen: {error}",
|
||||
"deleteNetworkError": "Netzwerkfehler beim Löschen.",
|
||||
"uploadSectionTitle": "Titel hochladen",
|
||||
"uploadSectionDescription": "Ziehe eine oder mehrere MP3-Dateien hierher oder wähle sie aus. Die Titel werden automatisch analysiert (inkl. Erkennung des Erscheinungsjahres) und von der globalen Playlist ausgeschlossen. Wähle mindestens eines deiner Genres aus, um die Titel zuzuordnen.",
|
||||
"dropzoneTitleEmpty": "MP3-Dateien hierher ziehen",
|
||||
"dropzoneTitleWithFiles": "{count} Datei(en) ausgewählt",
|
||||
"dropzoneSubtitle": "oder klicken, um Dateien auszuwählen",
|
||||
"selectedFilesTitle": "Ausgewählte Dateien:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Genres zuordnen",
|
||||
"noAssignedGenres": "Dir sind noch keine Genres zugeordnet. Bitte wende dich an den Admin.",
|
||||
"uploadButtonIdle": "Upload starten",
|
||||
"uploadButtonUploading": "Lade hoch...",
|
||||
"uploadSummary": "✅ {success}/{total} Uploads erfolgreich.",
|
||||
"uploadSummaryDuplicates": "⚠️ {count} Duplikat(e) übersprungen.",
|
||||
"uploadSummaryFailed": "❌ {count} fehlgeschlagen.",
|
||||
"uploadResultSuccess": "✅ erfolgreich",
|
||||
"uploadResultDuplicate": "⚠️ Duplikat: {error}",
|
||||
"uploadResultError": "❌ Fehler: {error}",
|
||||
"tracklistTitle": "Titel in deinen Genres & Specials ({count} Titel)",
|
||||
"tracklistDescription": "Du kannst Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Löschen ist nur erlaubt, wenn ein Song ausschließlich deinen Genres/Specials zugeordnet ist. Genres, Specials, News und politische Statements können nur vom Admin verwaltet werden.",
|
||||
"searchPlaceholder": "Nach Titel oder Artist suchen...",
|
||||
"filterAll": "Alle Inhalte",
|
||||
"filterNoGlobal": "🚫 Ohne Global",
|
||||
"filterReset": "Filter zurücksetzen",
|
||||
"noSongsInScope": "Keine passenden Songs in deinen Genres/Specials gefunden.",
|
||||
"columnId": "ID",
|
||||
"columnPlay": "Play",
|
||||
"columnTitle": "Titel",
|
||||
"columnArtist": "Artist",
|
||||
"columnYear": "Jahr",
|
||||
"columnGenresSpecials": "Genres / Specials",
|
||||
"columnAdded": "Hinzugefügt",
|
||||
"columnActivations": "Aktivierungen",
|
||||
"columnRating": "Rating",
|
||||
"columnExcludeGlobal": "Exclude Global",
|
||||
"columnActions": "Aktionen",
|
||||
"play": "Abspielen",
|
||||
"pause": "Pause",
|
||||
"excludeGlobalYes": "Ja",
|
||||
"excludeGlobalNo": "Nein",
|
||||
"excludeGlobalInfo": "Nur globale Kuratoren dürfen dieses Flag ändern.",
|
||||
"paginationPrev": "Zurück",
|
||||
"paginationNext": "Weiter",
|
||||
"paginationLabel": "Seite {page} von {total}",
|
||||
"loadingData": "Lade Daten...",
|
||||
"loggedInAs": "Eingeloggt als {username}",
|
||||
"globalCuratorSuffix": " (Globaler Kurator)",
|
||||
"pageSizeLabel": "Pro Seite:"
|
||||
},
|
||||
"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:",
|
||||
"costsTitle": "Laufende Kosten des Projekts",
|
||||
"costsIntro": "Auch wenn Hördle ein privates Projekt ist, entstehen für den Betrieb laufende Kosten, zum Beispiel:",
|
||||
"costsDonationNote": "Alle Einnahmen, die die Betriebskosten des Projekts übersteigen, werden am Jahresende an die Aktion <link>Zentrum für politische Schönheit</link> gespendet.",
|
||||
"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 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",
|
||||
"supportCuratorTitle": "Als Kurator bewerben",
|
||||
"supportCuratorText": "Du hast gute Kenntnisse in einem Genre und möchtest dich als Kurator bewerben? Wir freuen uns über deine Nachricht!",
|
||||
"supportReportBugTitle": "Fehler melden",
|
||||
"supportReportBugText": "Fehler in der App gefunden? Bitte melde sie per E-Mail an <email>admin@hoerdle.de</email>.",
|
||||
"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.",
|
||||
"backTitle": "Zurück zum Spiel",
|
||||
"backToGame": "Zurück zu Hördle",
|
||||
"footerLinkLabel": "Über & Impressum"
|
||||
}
|
||||
}
|
||||
283
messages/en.json
Normal file
283
messages/en.json
Normal file
@@ -0,0 +1,283 @@
|
||||
{
|
||||
"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",
|
||||
"shareExplanation": "Share your result with friends – your support helps Hördle grow.",
|
||||
"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:",
|
||||
"ratingTooltip": "Help our curators create good puzzles!",
|
||||
"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"
|
||||
},
|
||||
"ExtraPuzzles": {
|
||||
"title": "Still in the mood for puzzles?",
|
||||
"message": "Hey, would you like to try some more puzzles? Then take a look at {name}!",
|
||||
"cta": "Go to {name}",
|
||||
"close": "Close"
|
||||
},
|
||||
"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",
|
||||
"manageCurators": "Manage Curators",
|
||||
"addCurator": "Add Curator",
|
||||
"curatorUsername": "Username",
|
||||
"curatorPassword": "Password (leave empty to keep)",
|
||||
"isGlobalCurator": "Global curator (may change global flag)",
|
||||
"assignedGenres": "Assigned genres",
|
||||
"assignedSpecials": "Assigned specials",
|
||||
"noCurators": "No curators created yet."
|
||||
},
|
||||
"Curator": {
|
||||
"loginTitle": "Curator Login",
|
||||
"loginUsername": "Username",
|
||||
"loginPassword": "Password",
|
||||
"loginButton": "Log in",
|
||||
"logout": "Logout",
|
||||
"loginFailed": "Login failed.",
|
||||
"loginNetworkError": "Network error during login.",
|
||||
"loadCuratorError": "Failed to load curator information.",
|
||||
"loadSongsError": "Failed to load songs.",
|
||||
"songUpdated": "Song updated successfully.",
|
||||
"saveError": "Error while saving: {error}",
|
||||
"saveNetworkError": "Network error while saving.",
|
||||
"noDeletePermission": "You are not allowed to delete this song.",
|
||||
"deleteConfirm": "Do you really want to delete \"{title}\"?",
|
||||
"songDeleted": "Song deleted.",
|
||||
"deleteError": "Error while deleting: {error}",
|
||||
"deleteNetworkError": "Network error while deleting.",
|
||||
"uploadSectionTitle": "Upload titles",
|
||||
"uploadSectionDescription": "Drag one or more MP3 files here or select them. The titles will be analysed automatically (including detection of the release year) and excluded from the global playlist. Select at least one of your genres to assign the titles.",
|
||||
"dropzoneTitleEmpty": "Drag MP3 files here",
|
||||
"dropzoneTitleWithFiles": "{count} file(s) selected",
|
||||
"dropzoneSubtitle": "or click to select files",
|
||||
"selectedFilesTitle": "Selected files:",
|
||||
"uploadProgress": "Upload: {current} / {total}",
|
||||
"assignGenresLabel": "Assign genres",
|
||||
"noAssignedGenres": "No genres are assigned to you yet. Please contact the admin.",
|
||||
"uploadButtonIdle": "Start upload",
|
||||
"uploadButtonUploading": "Uploading...",
|
||||
"uploadSummary": "✅ {success}/{total} uploads successful.",
|
||||
"uploadSummaryDuplicates": "⚠️ {count} duplicate(s) skipped.",
|
||||
"uploadSummaryFailed": "❌ {count} failed.",
|
||||
"uploadResultSuccess": "✅ successful",
|
||||
"uploadResultDuplicate": "⚠️ Duplicate: {error}",
|
||||
"uploadResultError": "❌ Error: {error}",
|
||||
"tracklistTitle": "Titles in your genres & specials ({count} titles)",
|
||||
"tracklistDescription": "You can edit songs that are assigned to at least one of your genres or specials. Deletion is only allowed if a song is assigned exclusively to your genres/specials. Genres, specials, news and political statements can only be managed by the admin.",
|
||||
"searchPlaceholder": "Search by title or artist...",
|
||||
"filterAll": "All content",
|
||||
"filterNoGlobal": "🚫 No global",
|
||||
"filterReset": "Reset filters",
|
||||
"noSongsInScope": "No matching songs in your genres/specials.",
|
||||
"columnId": "ID",
|
||||
"columnPlay": "Play",
|
||||
"columnTitle": "Title",
|
||||
"columnArtist": "Artist",
|
||||
"columnYear": "Year",
|
||||
"columnGenresSpecials": "Genres / Specials",
|
||||
"columnAdded": "Added",
|
||||
"columnActivations": "Activations",
|
||||
"columnRating": "Rating",
|
||||
"columnExcludeGlobal": "Exclude global",
|
||||
"columnActions": "Actions",
|
||||
"play": "Play",
|
||||
"pause": "Pause",
|
||||
"excludeGlobalYes": "Yes",
|
||||
"excludeGlobalNo": "No",
|
||||
"excludeGlobalInfo": "Only global curators may change this flag.",
|
||||
"paginationPrev": "Previous",
|
||||
"paginationNext": "Next",
|
||||
"paginationLabel": "Page {page} of {total}",
|
||||
"loadingData": "Loading data...",
|
||||
"loggedInAs": "Logged in as {username}",
|
||||
"globalCuratorSuffix": " (Global curator)",
|
||||
"pageSizeLabel": "Per page:"
|
||||
},
|
||||
"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:",
|
||||
"costsTitle": "Ongoing costs of the project",
|
||||
"costsIntro": "Even though Hördle is a private project, there are ongoing costs for running it, for example:",
|
||||
"costsDonationNote": "All income that exceeds the operating costs of the project will be donated at the end of the year to the campaign <link>Zentrum für politische Schönheit</link>.",
|
||||
"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 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",
|
||||
"supportCuratorTitle": "Apply as Curator",
|
||||
"supportCuratorText": "Do you have good knowledge in a genre and would like to apply as a curator? We'd be happy to hear from you!",
|
||||
"supportReportBugTitle": "Report Bugs",
|
||||
"supportReportBugText": "Found a bug in the app? Please report it via email to <email>admin@hoerdle.de</email>.",
|
||||
"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.",
|
||||
"backTitle": "Back to the game",
|
||||
"backToGame": "Back to Hördle",
|
||||
"footerLinkLabel": "About & Imprint"
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next();
|
||||
|
||||
// Security Headers
|
||||
const headers = response.headers;
|
||||
|
||||
// Prevent clickjacking
|
||||
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
||||
|
||||
// XSS Protection (legacy but still useful)
|
||||
headers.set('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Referrer Policy
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
// Permissions Policy (restrict features)
|
||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
// Content Security Policy
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval
|
||||
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' https://openrouter.ai https://gotify.example.com",
|
||||
"media-src 'self' blob:",
|
||||
"frame-ancestors 'self'",
|
||||
].join('; ');
|
||||
headers.set('Content-Security-Policy', csp);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Apply middleware to all routes
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
reactCompiler: true,
|
||||
@@ -8,7 +11,7 @@ const nextConfig: NextConfig = {
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
middlewareClientMaxBodySize: '50mb',
|
||||
proxyClientMaxBodySize: '50mb',
|
||||
},
|
||||
env: {
|
||||
TZ: process.env.TZ || 'Europe/Berlin',
|
||||
@@ -36,4 +39,4 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withNextIntl(nextConfig);
|
||||
|
||||
1766
package-lock.json
generated
1766
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoerdle",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -11,11 +11,15 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"music-metadata": "^11.10.2",
|
||||
"next": "16.0.3",
|
||||
"next-intl": "^4.5.6",
|
||||
"prisma": "^6.19.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"unist-util-visit-parents": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
@@ -23,6 +27,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"typescript": "^5"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "News" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT 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
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "News_publishedAt_idx" ON "News"("publishedAt");
|
||||
@@ -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;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlayerState" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"genreKey" TEXT NOT NULL,
|
||||
"gameState" TEXT NOT NULL,
|
||||
"statistics" TEXT NOT NULL,
|
||||
"lastPlayed" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlayerState_identifier_idx" ON "PlayerState"("identifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PlayerState_identifier_genreKey_key" ON "PlayerState"("identifier", "genreKey");
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PoliticalStatement" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"locale" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"source" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PoliticalStatement_locale_active_idx" ON "PoliticalStatement"("locale", "active");
|
||||
@@ -0,0 +1,36 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Curator" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"username" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"isGlobalCurator" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CuratorGenre" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"curatorId" INTEGER NOT NULL,
|
||||
"genreId" INTEGER NOT NULL,
|
||||
CONSTRAINT "CuratorGenre_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CuratorGenre_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CuratorSpecial" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"curatorId" INTEGER NOT NULL,
|
||||
"specialId" INTEGER NOT NULL,
|
||||
CONSTRAINT "CuratorSpecial_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "CuratorSpecial_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Curator_username_key" ON "Curator"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CuratorGenre_curatorId_genreId_key" ON "CuratorGenre"("curatorId", "genreId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CuratorSpecial_curatorId_specialId_key" ON "CuratorSpecial"("curatorId", "specialId");
|
||||
@@ -28,17 +28,18 @@ model Song {
|
||||
|
||||
model Genre {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
subtitle String?
|
||||
name Json // Multilingual: { "de": "Rock", "en": "Rock" }
|
||||
subtitle Json? // Multilingual
|
||||
active Boolean @default(true)
|
||||
songs Song[]
|
||||
dailyPuzzles DailyPuzzle[]
|
||||
curatorGenres CuratorGenre[]
|
||||
}
|
||||
|
||||
model Special {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
subtitle String?
|
||||
name Json // Multilingual
|
||||
subtitle Json? // Multilingual
|
||||
maxAttempts Int @default(7)
|
||||
unlockSteps String // JSON string: e.g. "[2, 4, 7, 11, 16, 30]"
|
||||
createdAt DateTime @default(now())
|
||||
@@ -47,6 +48,8 @@ model Special {
|
||||
curator String?
|
||||
songs SpecialSong[]
|
||||
puzzles DailyPuzzle[]
|
||||
news News[]
|
||||
curatorSpecials CuratorSpecial[]
|
||||
}
|
||||
|
||||
model SpecialSong {
|
||||
@@ -73,3 +76,76 @@ model DailyPuzzle {
|
||||
|
||||
@@unique([date, genreId, specialId])
|
||||
}
|
||||
|
||||
model News {
|
||||
id Int @id @default(autoincrement())
|
||||
title Json // Multilingual
|
||||
content Json // Multilingual
|
||||
author String? // Optional: curator/admin name
|
||||
publishedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
featured Boolean @default(false) // Highlight important news
|
||||
specialId Int? // Optional: link to a special
|
||||
special Special? @relation(fields: [specialId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([publishedAt])
|
||||
}
|
||||
|
||||
model PlayerState {
|
||||
id Int @id @default(autoincrement())
|
||||
identifier String // UUID des Spielers (für Cross-Domain-Sync)
|
||||
genreKey String // Genre-Name oder "global" oder "special:<name>"
|
||||
gameState String // JSON-String des GameState
|
||||
statistics String // JSON-String der Statistics
|
||||
lastPlayed DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([identifier, genreKey])
|
||||
@@index([identifier])
|
||||
}
|
||||
|
||||
model Curator {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
isGlobalCurator Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
genres CuratorGenre[]
|
||||
specials CuratorSpecial[]
|
||||
}
|
||||
|
||||
model CuratorGenre {
|
||||
id Int @id @default(autoincrement())
|
||||
curatorId Int
|
||||
genreId Int
|
||||
|
||||
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([curatorId, genreId])
|
||||
}
|
||||
|
||||
model CuratorSpecial {
|
||||
id Int @id @default(autoincrement())
|
||||
curatorId Int
|
||||
specialId Int
|
||||
|
||||
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
|
||||
special Special @relation(fields: [specialId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([curatorId, specialId])
|
||||
}
|
||||
|
||||
model PoliticalStatement {
|
||||
id Int @id @default(autoincrement())
|
||||
locale String
|
||||
text String
|
||||
active Boolean @default(true)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([locale, active])
|
||||
}
|
||||
|
||||
68
proxy.ts
Normal file
68
proxy.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
const i18nMiddleware = createMiddleware({
|
||||
locales: ['en', 'de'],
|
||||
defaultLocale: 'en',
|
||||
// Wir nutzen überall Locale-Präfixe (`/en`, `/de`)
|
||||
localePrefix: 'always'
|
||||
});
|
||||
|
||||
export default function proxy(request: NextRequest) {
|
||||
// 1. i18n-Routing
|
||||
const response = i18nMiddleware(request);
|
||||
|
||||
// 2. Security-Header ergänzen
|
||||
const headers = response.headers;
|
||||
|
||||
headers.set('X-Frame-Options', 'SAMEORIGIN');
|
||||
headers.set('X-XSS-Protection', '1; mode=block');
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
|
||||
// Extract Plausible domain from script URL for CSP
|
||||
const plausibleScriptSrc = process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC || 'https://plausible.example.com/js/script.js';
|
||||
let plausibleOrigin = 'https://plausible.example.com';
|
||||
try {
|
||||
const url = new URL(plausibleScriptSrc);
|
||||
plausibleOrigin = url.origin;
|
||||
} catch {
|
||||
// If URL parsing fails, try to extract domain manually
|
||||
const match = plausibleScriptSrc.match(/https?:\/\/([^/]+)/);
|
||||
if (match) {
|
||||
plausibleOrigin = `https://${match[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Get other service URLs from environment (only add to CSP if configured)
|
||||
const gotifyUrl = process.env.GOTIFY_URL;
|
||||
const openrouterUrl = process.env.NEXT_PUBLIC_OPENROUTER_URL || 'https://openrouter.ai';
|
||||
|
||||
// Build CSP dynamically based on environment variables
|
||||
const connectSrcParts = ["'self'", openrouterUrl, plausibleOrigin];
|
||||
if (gotifyUrl && !gotifyUrl.includes('example.com')) {
|
||||
connectSrcParts.push(gotifyUrl);
|
||||
}
|
||||
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'unsafe-inline' 'unsafe-eval' ${plausibleOrigin}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self' data:",
|
||||
`connect-src ${connectSrcParts.join(' ')}`,
|
||||
"media-src 'self' blob:",
|
||||
"frame-ancestors 'self'",
|
||||
].join('; ');
|
||||
|
||||
headers.set('Content-Security-Policy', csp);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Empfohlener Matcher aus der next-intl Doku:
|
||||
// alle Routen außer _next, API und statischen Dateien
|
||||
matcher: ['/((?!api|_next|.*\\..*).*)']
|
||||
};
|
||||
@@ -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 "20251123181922_add_release_year"
|
||||
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."
|
||||
|
||||
94
scripts/check-caddy-certificates.sh
Executable file
94
scripts/check-caddy-certificates.sh
Executable file
@@ -0,0 +1,94 @@
|
||||
#!/bin/bash
|
||||
# Script zum Prüfen der Caddy-Zertifikate
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Prüfe Caddy-Zertifikat-Status..."
|
||||
echo ""
|
||||
|
||||
# Prüfe ob Caddy-Container läuft
|
||||
if ! docker ps | grep -q hoerdle-caddy; then
|
||||
echo "❌ Caddy-Container läuft nicht!"
|
||||
echo " Starte ihn mit: docker compose -f docker-compose.caddy.yml --profile production up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Caddy-Container läuft"
|
||||
echo ""
|
||||
|
||||
# Prüfe Zertifikate im Volume
|
||||
echo "📜 Gespeicherte Zertifikate im Volume:"
|
||||
docker exec hoerdle-caddy find /data/caddy/certificates -name "*.crt" -o -name "*.key" 2>/dev/null | head -20 || echo " (Keine Zertifikate gefunden oder Fehler beim Zugriff)"
|
||||
echo ""
|
||||
|
||||
# Prüfe Caddy-Logs für Zertifikats-Fehler
|
||||
echo "📋 Letzte Caddy-Logs (Zertifikat-bezogen):"
|
||||
docker logs hoerdle-caddy 2>&1 | grep -i -E "(certificate|tls|acme|challenge|error)" | tail -20 || echo " (Keine relevanten Log-Einträge gefunden)"
|
||||
echo ""
|
||||
|
||||
# Prüfe DNS-Einträge
|
||||
echo "🌐 DNS-Einträge prüfen:"
|
||||
echo ""
|
||||
|
||||
# Prüfe hoerdle.de
|
||||
echo "1️⃣ hoerdle.de:"
|
||||
HOERDLE_IP=$(dig +short hoerdle.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$HOERDLE_IP" ] && [ "$HOERDLE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $HOERDLE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe xn--hrdle-jua.de (Punycode)
|
||||
echo "2️⃣ xn--hrdle-jua.de (hördle.de):"
|
||||
PUNYCODE_IP=$(dig +short xn--hrdle-jua.de @8.8.8.8 | head -1 || echo "DNS-Fehler")
|
||||
if [ -n "$PUNYCODE_IP" ] && [ "$PUNYCODE_IP" != "DNS-Fehler" ]; then
|
||||
echo " ✅ DNS-A-Record: $PUNYCODE_IP"
|
||||
else
|
||||
echo " ❌ DNS-A-Record: nicht aufgelöst"
|
||||
fi
|
||||
|
||||
# Prüfe auch die Unicode-Domain direkt (falls unterstützt)
|
||||
echo "3️⃣ hördle.de (Unicode):"
|
||||
UNICODE_IP=$(dig +short hördle.de @8.8.8.8 2>/dev/null | head -1 || echo "Nicht unterstützt")
|
||||
if [ -n "$UNICODE_IP" ] && [ "$UNICODE_IP" != "Nicht unterstützt" ]; then
|
||||
echo " ✅ DNS-A-Record: $UNICODE_IP"
|
||||
else
|
||||
echo " ⚠️ Unicode-Domain-Abfrage nicht unterstützt (normal)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔒 Zertifikat-Test:"
|
||||
echo ""
|
||||
|
||||
# Teste HTTPS-Verbindung zu hoerdle.de
|
||||
echo "1️⃣ hoerdle.de HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect hoerdle.de:443 -servername hoerdle.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "2️⃣ hördle.de (xn--hrdle-jua.de) HTTPS:"
|
||||
if echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✅ Zertifikat gültig"
|
||||
CERT_INFO=$(echo | timeout 5 openssl s_client -connect xn--hrdle-jua.de:443 -servername xn--hrdle-jua.de 2>/dev/null | openssl x509 -noout -subject -dates 2>/dev/null || echo "")
|
||||
echo " $CERT_INFO" | sed 's/^/ /'
|
||||
else
|
||||
echo " ❌ Zertifikat fehlt oder ungültig"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📝 Zertifikat-Details aus Caddy Volume:"
|
||||
echo ""
|
||||
docker exec hoerdle-caddy ls -la /data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/ 2>/dev/null | head -10 || echo " (Verzeichnis nicht gefunden oder leer)"
|
||||
|
||||
echo ""
|
||||
echo "💡 Tipps:"
|
||||
echo " - Wenn kein Zertifikat vorhanden ist, führe aus: ./scripts/renew-caddy-certificates.sh"
|
||||
echo " - Prüfe die Caddy-Logs: docker logs hoerdle-caddy"
|
||||
echo " - Prüfe DNS-Einträge in GoDaddy: https://dcc.godaddy.com/manage/hoerdle.de/dns"
|
||||
|
||||
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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user