Compare commits
225 Commits
v0.1.0.8
...
6be813fb00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be813fb00 | ||
|
|
71c7f2aab5 | ||
|
|
096682929d | ||
|
|
cebdf7a5a2 | ||
|
|
afbdb74516 | ||
|
|
9372264174 | ||
|
|
25680a19b6 | ||
|
|
fb3e4c10dd | ||
|
|
b7293a4614 | ||
|
|
830e91fdff | ||
|
|
bc95af8027 | ||
|
|
56461fe0bb | ||
|
|
989654f62e | ||
|
|
bf9fbe37c0 | ||
|
|
c83dc7a5e5 | ||
|
|
7999d63e6d | ||
|
|
2bf21fd75f | ||
|
|
e48d823c92 | ||
|
|
84822e79ca | ||
|
|
17856ef09b | ||
|
|
fb833a7976 | ||
|
|
a4e61de53f | ||
|
|
73c1c1cf89 | ||
|
|
83e1281079 | ||
|
|
2e1f1e599b | ||
|
|
71c4e2509f | ||
|
|
9cef1c78d3 | ||
|
|
6741eeb7fa | ||
|
|
71b8e98f23 | ||
|
|
bc2c0bad59 | ||
|
|
812d6ff10d | ||
|
|
aed300b1bb | ||
|
|
e93b3b9096 | ||
|
|
cdd2ff15d5 | ||
|
|
adcfbfa811 | ||
|
|
0cdfe90476 | ||
|
|
1715ca02ed | ||
|
|
ece3991d37 | ||
|
|
fa3b64f490 | ||
|
|
fa6f1097dd | ||
|
|
d2ec0119ce | ||
|
|
8914c552cd | ||
|
|
d816422419 | ||
|
|
da777ffcf3 | ||
|
|
0d806daf66 | ||
|
|
616cfec3e7 | ||
|
|
ac12e45393 | ||
|
|
223eb62973 | ||
|
|
dc4bdd36c7 | ||
|
|
136f881252 | ||
|
|
fd11048f2c | ||
|
|
c1b448639e | ||
|
|
97021f016b | ||
|
|
1991cbd93f | ||
|
|
c28c9fe8f0 | ||
|
|
803713dea7 | ||
|
|
0e6eba64d9 | ||
|
|
576b486caf | ||
|
|
d8f69631b5 | ||
|
|
dbcdaf9278 | ||
|
|
2e93d09236 | ||
|
|
a1fe62f132 | ||
|
|
e49c6acc99 | ||
|
|
96cc9db7d6 | ||
|
|
ebc482dc87 | ||
|
|
88dd86c344 | ||
|
|
623e8b9b82 | ||
|
|
286ac2d28a | ||
|
|
c02d3df7ed | ||
|
|
702f47b7e5 | ||
|
|
86f3349f80 | ||
|
|
bdb74fb462 | ||
|
|
66c0071257 | ||
|
|
76f14087fd | ||
|
|
b1ab5bd633 | ||
|
|
51c62e7763 | ||
|
|
de6eadfe62 | ||
|
|
b033c3a1bc | ||
|
|
4b7121271a | ||
|
|
12cc81905e | ||
|
|
b46e9e3882 | ||
|
|
332688d693 | ||
|
|
a725694519 | ||
|
|
cdb9803b40 | ||
|
|
7db4e26b2c | ||
|
|
b204a35628 | ||
|
|
c62f8f91e5 | ||
|
|
6fbb3f4718 | ||
|
|
5136c3add1 | ||
|
|
c250b5fff9 | ||
|
|
4074cdfe00 | ||
|
|
65425ac15c | ||
|
|
7879b63498 | ||
|
|
91ebaa0e44 | ||
|
|
a61caa2d13 | ||
|
|
52a15b7504 | ||
|
|
00160d9602 | ||
|
|
296a227d22 | ||
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 | ||
|
|
d2548c2870 | ||
|
|
40d6ea75f0 | ||
|
|
0054facbe7 | ||
|
|
95bcf9ed1e | ||
|
|
08fedf9881 | ||
|
|
cd564b5d8c | ||
|
|
863539a5e9 | ||
|
|
2fa8aa0042 | ||
|
|
8ecf430bf5 | ||
|
|
71abb7c322 | ||
|
|
b730c6637a | ||
|
|
6e93529bc3 | ||
|
|
990e1927e9 | ||
|
|
d7fee047c2 | ||
|
|
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 |
1
.cursor/commands/bump.md
Normal file
1
.cursor/commands/bump.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
teste den build (npm run build), anschließend commit, dann bump zum nächsten patchlevel, git tag und sync
|
||||||
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
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -50,3 +51,7 @@ next-env.d.ts
|
|||||||
/data
|
/data
|
||||||
.release-years-migrated
|
.release-years-migrated
|
||||||
.covers-migrated
|
.covers-migrated
|
||||||
|
docker-compose.yml
|
||||||
|
scripts/scrape-bahn-expert-statements.js
|
||||||
|
docs/bahn-expert-statements.txt
|
||||||
|
/public/logos.zip
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
35
Dockerfile
35
Dockerfile
@@ -23,11 +23,15 @@ RUN apk add --no-cache git
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Extract version: use build arg if provided, otherwise get from git
|
# Extract version: use build arg if provided, otherwise get from git, fallback to package.json
|
||||||
|
# Only use tags that are reachable from the current commit to ensure version matches the code
|
||||||
RUN if [ -n "$APP_VERSION" ]; then \
|
RUN if [ -n "$APP_VERSION" ]; then \
|
||||||
echo "$APP_VERSION" > /tmp/version.txt; \
|
echo "$APP_VERSION" > /tmp/version.txt; \
|
||||||
else \
|
else \
|
||||||
git describe --tags --always 2>/dev/null > /tmp/version.txt || echo "unknown" > /tmp/version.txt; \
|
(git describe --tags --exact-match 2>/dev/null || \
|
||||||
|
git describe --tags --abbrev=0 2>/dev/null || \
|
||||||
|
(grep -o '"version": "[^"]*"' package.json 2>/dev/null | cut -d'"' -f4 | sed 's/^/v/') || \
|
||||||
|
echo "dev") > /tmp/version.txt; \
|
||||||
fi && \
|
fi && \
|
||||||
echo "Building version: $(cat /tmp/version.txt)"
|
echo "Building version: $(cat /tmp/version.txt)"
|
||||||
|
|
||||||
@@ -36,10 +40,37 @@ RUN if [ -n "$APP_VERSION" ]; then \
|
|||||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Suppress baseline-browser-mapping warning about old data (informational only)
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Generate Prisma Client
|
# Generate Prisma Client
|
||||||
ENV DATABASE_URL="file:./dev.db"
|
ENV DATABASE_URL="file:./dev.db"
|
||||||
RUN node_modules/.bin/prisma generate
|
RUN node_modules/.bin/prisma generate
|
||||||
|
|
||||||
|
# White Label Build Arguments
|
||||||
|
ARG NEXT_PUBLIC_APP_NAME
|
||||||
|
ARG NEXT_PUBLIC_APP_DESCRIPTION
|
||||||
|
ARG NEXT_PUBLIC_DOMAIN
|
||||||
|
ARG NEXT_PUBLIC_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
|
RUN npm run build
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
|
|||||||
10
Dockerfile.caddy
Normal file
10
Dockerfile.caddy
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Dockerfile für Caddy mit GoDaddy DNS-Provider Plugin
|
||||||
|
FROM caddy:2-builder AS builder
|
||||||
|
|
||||||
|
RUN xcaddy build \
|
||||||
|
--with github.com/caddy-dns/godaddy
|
||||||
|
|
||||||
|
FROM caddy:2
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
|
|
||||||
101
README.md
101
README.md
@@ -4,6 +4,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- **🌍 Mehrsprachigkeit (i18n):** Vollständige Unterstützung für Deutsch und Englisch mit automatischer Sprachumleitung und lokalisierten Inhalten.
|
||||||
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
|
- **Tägliches Rätsel:** Jeden Tag ein neuer Song für alle Nutzer.
|
||||||
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
- **Inkrementelle Hinweise:** Startet mit 2 Sekunden, dann 4s, 7s, 11s, 16s, 30s, bis 60s (7 Versuche).
|
||||||
- **Admin Dashboard:**
|
- **Admin Dashboard:**
|
||||||
@@ -14,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Bearbeitung von Metadaten.
|
- Bearbeitung von Metadaten.
|
||||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||||
|
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
|
||||||
- **Cover Art:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||||
@@ -41,15 +43,55 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Live-Vorschau beim Hovern über die Waveform.
|
- Live-Vorschau beim Hovern über die Waveform.
|
||||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
|
||||||
- Manuelle Speicherung mit visueller Bestätigung.
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
- **News & Announcements:**
|
- **News & Announcements:**
|
||||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||||
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
- **Markdown Support:** Formatierung von Texten, Links und Listen.
|
||||||
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
- **Homepage Integration:** Dezentrale Anzeige auf der Startseite (collapsible).
|
||||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
- **Special-Verknüpfung:** Direkte Links zu Specials in News-Beiträgen.
|
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||||
- Verwaltung über das Admin-Dashboard.
|
- Verwaltung über das Admin-Dashboard.
|
||||||
|
- **Kurator-System:**
|
||||||
|
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
|
||||||
|
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
|
||||||
|
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
|
||||||
|
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
|
||||||
|
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
|
||||||
|
- **Curate Specials:** Kuratoren können in einem eigenen Bereich („Curate Specials“) die Startzeiten der Songs in ihren zugewiesenen Specials über den Waveform-Editor einstellen – streng begrenzt auf ihre eigenen Specials.
|
||||||
|
- **Batch-Edit:** Mehrere Titel gleichzeitig bearbeiten (Genre/Special Toggle, Artist ändern, Exclude Global Flag setzen).
|
||||||
|
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
|
||||||
|
- **Spieler-Kommentare:**
|
||||||
|
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
|
||||||
|
- **KI-gestützte Formulierungshilfe:** Nachrichten können vor dem Absenden auf Wunsch automatisch von einer KI umformuliert/verbessert werden.
|
||||||
|
- **Einklappbares Kommentar-Formular:** Das Nachrichtenformular ist dezent als einklappbarer Bereich eingebunden und stört den Spielfluss nicht.
|
||||||
|
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
|
||||||
|
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
|
||||||
|
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
|
||||||
|
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
|
||||||
|
- **Analytics:**
|
||||||
|
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
|
||||||
|
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
|
||||||
|
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
|
||||||
|
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
|
||||||
|
|
||||||
|
## 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
|
## Spielregeln & Punktesystem
|
||||||
|
|
||||||
@@ -57,13 +99,15 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
|||||||
|
|
||||||
- **Start-Punktestand:** 90 Punkte
|
- **Start-Punktestand:** 90 Punkte
|
||||||
- **Richtige Antwort:** +20 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
|
- **Überspringen (Skip):** -5 Punkte
|
||||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||||
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework:** Next.js 16 (App Router)
|
- **Framework:** Next.js 16 (App Router)
|
||||||
@@ -95,12 +139,14 @@ Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und d
|
|||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
Die App läuft unter `http://localhost:3000`.
|
Die App läuft unter `http://localhost:3000` (leitet automatisch zu `/en` um).
|
||||||
|
|
||||||
## Deployment mit Docker
|
## Deployment mit Docker
|
||||||
|
|
||||||
Das Projekt ist für den Betrieb mit Docker optimiert.
|
Das Projekt ist für den Betrieb mit Docker optimiert.
|
||||||
|
|
||||||
|
👉 **[White Labeling mit Docker? Hier klicken!](docs/WHITE_LABEL.md#docker-deployment)**
|
||||||
|
|
||||||
1. **Vorbereitung:**
|
1. **Vorbereitung:**
|
||||||
Kopiere die Beispiel-Konfiguration:
|
Kopiere die Beispiel-Konfiguration:
|
||||||
```bash
|
```bash
|
||||||
@@ -115,6 +161,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||||
|
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
|
||||||
|
|
||||||
2. **Starten:**
|
2. **Starten:**
|
||||||
```bash
|
```bash
|
||||||
@@ -129,23 +176,36 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- Beim Start des Containers wird automatisch ein Migrations-Skript ausgeführt, das fehlende Cover-Bilder aus den MP3s extrahiert.
|
- Beim Start des Containers wird automatisch ein Migrations-Skript ausgeführt, das fehlende Cover-Bilder aus den MP3s extrahiert.
|
||||||
|
|
||||||
4. **Admin-Zugang:**
|
4. **Admin-Zugang:**
|
||||||
- URL: `/admin`
|
- URL: `/de/admin` oder `/en/admin`
|
||||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||||
|
|
||||||
5. **Special Curation & Scheduling verwenden:**
|
5. **Kurator-Zugang:**
|
||||||
|
- URL: `/de/curator` oder `/en/curator`
|
||||||
|
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
|
||||||
|
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
|
||||||
|
- **Batch-Edit-Funktionalität:**
|
||||||
|
- Mehrere Titel über Checkboxen auswählen
|
||||||
|
- Genre/Special Toggle (hinzufügen/entfernen)
|
||||||
|
- Artist-Änderung für alle ausgewählten Titel
|
||||||
|
- Exclude Global Flag setzen/entfernen (nur für Global-Kuratoren)
|
||||||
|
- Toolbar erscheint automatisch bei Auswahl von Titeln
|
||||||
|
|
||||||
|
6. **Special Curation & Scheduling verwenden:**
|
||||||
- Erstelle ein Special im Admin-Dashboard:
|
- Erstelle ein Special im Admin-Dashboard:
|
||||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||||
- **Optional:** Trage einen Kurator ein.
|
- **Optional:** Trage einen Kurator ein.
|
||||||
- Weise Songs dem Special zu (über die Song-Bibliothek).
|
- Weise Songs dem Special zu (über die Song-Bibliothek).
|
||||||
- Klicke auf "Curate" neben dem Special.
|
- Die eigentliche Kuratierung (Auswahl des Ausschnitts) findet im **Kuratoren-Dashboard** statt:
|
||||||
- Nutze den Waveform-Editor um den perfekten Ausschnitt zu wählen:
|
- Logge dich als Kurator ein und gehe zu `/de/curator` oder `/en/curator`.
|
||||||
- **Klicken:** Positioniert die Selektion
|
- Klicke im Dashboard auf **„Curate Specials“**, um eine Liste deiner zugewiesenen Specials zu sehen.
|
||||||
- **Hovern:** Zeigt Vorschau der neuen Position
|
- Öffne ein Special und nutze dort den Waveform-Editor, um den perfekten Ausschnitt zu wählen:
|
||||||
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
|
- **Klicken:** Positioniert die Selektion
|
||||||
- **Pan:** ← / → Buttons zum Verschieben der Ansicht
|
- **Hovern:** Zeigt Vorschau der neuen Position
|
||||||
- **Segment-Playback:** Teste einzelne Puzzle-Abschnitte
|
- **Zoom:** 🔍+ / 🔍− Buttons für detaillierte Ansicht
|
||||||
- **Save:** Speichere Änderungen mit dem grünen Button
|
- **Pan:** ← / → Buttons zum Verschieben der Ansicht
|
||||||
|
- **Segment-Playback:** Teste einzelne Puzzle-Abschnitte
|
||||||
|
- **Save:** Speichere Änderungen mit dem grünen Button
|
||||||
- Die Spieler hören dann nur den kuratierten Ausschnitt.
|
- Die Spieler hören dann nur den kuratierten Ausschnitt.
|
||||||
- Auf der Startseite werden zukünftige Specials unter "Coming soon" angezeigt (mit Datum und Kurator).
|
- Auf der Startseite werden zukünftige Specials unter "Coming soon" angezeigt (mit Datum und Kurator).
|
||||||
|
|
||||||
@@ -200,12 +260,12 @@ Hördle kann problemlos als iFrame in andere Webseiten eingebettet werden. Die A
|
|||||||
|
|
||||||
### Genre-spezifische Einbindung
|
### Genre-spezifische Einbindung
|
||||||
|
|
||||||
Einzelne Genres können direkt eingebunden werden:
|
Einzelne Genres können direkt eingebunden werden (mit Locale-Präfix):
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<!-- Rock Genre -->
|
<!-- Rock Genre (Deutsch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/Rock"
|
src="https://hoerdle.elpatron.me/de/Rock"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@@ -213,9 +273,9 @@ Einzelne Genres können direkt eingebunden werden:
|
|||||||
title="Hördle Rock Quiz">
|
title="Hördle Rock Quiz">
|
||||||
</iframe>
|
</iframe>
|
||||||
|
|
||||||
<!-- Pop Genre -->
|
<!-- Pop Genre (Englisch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/Pop"
|
src="https://hoerdle.elpatron.me/en/Pop"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@@ -229,8 +289,9 @@ Einzelne Genres können direkt eingebunden werden:
|
|||||||
Auch thematische Specials können direkt eingebettet werden:
|
Auch thematische Specials können direkt eingebettet werden:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
|
<!-- Weihnachtslieder (Deutsch) -->
|
||||||
<iframe
|
<iframe
|
||||||
src="https://hoerdle.elpatron.me/special/Weihnachtslieder"
|
src="https://hoerdle.elpatron.me/de/special/Weihnachtslieder"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="800"
|
height="800"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import Game from '@/components/Game';
|
|
||||||
import NewsSection from '@/components/NewsSection';
|
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ genre: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function GenrePage({ params }: PageProps) {
|
|
||||||
const { genre } = await params;
|
|
||||||
const decodedGenre = decodeURIComponent(genre);
|
|
||||||
|
|
||||||
// Check if genre exists and is active
|
|
||||||
const currentGenre = await prisma.genre.findUnique({
|
|
||||||
where: { name: decodedGenre }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentGenre || !currentGenre.active) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(decodedGenre);
|
|
||||||
const genres = await prisma.genre.findMany({
|
|
||||||
where: { active: true },
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const activeSpecials = specials.filter(s => {
|
|
||||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
|
||||||
const isEnded = s.endDate && s.endDate < now;
|
|
||||||
return isStarted && !isEnded;
|
|
||||||
});
|
|
||||||
|
|
||||||
const upcomingSpecials = specials.filter(s => {
|
|
||||||
return s.launchDate && s.launchDate > now;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{genres.map(g => (
|
|
||||||
<Link
|
|
||||||
key={g.id}
|
|
||||||
href={`/${g.name}`}
|
|
||||||
style={{
|
|
||||||
fontWeight: g.name === decodedGenre ? 'bold' : 'normal',
|
|
||||||
textDecoration: g.name === decodedGenre ? 'underline' : 'none',
|
|
||||||
color: g.name === decodedGenre ? 'black' : '#4b5563'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
|
||||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Specials */}
|
|
||||||
{activeSpecials.map(s => (
|
|
||||||
<Link
|
|
||||||
key={s.id}
|
|
||||||
href={`/special/${s.name}`}
|
|
||||||
style={{
|
|
||||||
color: '#be185d', // Pink-700
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ {s.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upcoming Specials */}
|
|
||||||
{upcomingSpecials.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
|
||||||
Coming soon: {upcomingSpecials.map(s => (
|
|
||||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
|
||||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: process.env.TZ
|
|
||||||
}) : ''})
|
|
||||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<NewsSection />
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={decodedGenre} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
167
app/[locale]/[genre]/page.tsx
Normal file
167
app/[locale]/[genre]/page.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
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({
|
||||||
|
where: { hidden: false },
|
||||||
|
});
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
2445
app/[locale]/admin/page.tsx
Normal file
2445
app/[locale]/admin/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import SpecialEditorPage from '@/app/admin/specials/[id]/page';
|
||||||
|
|
||||||
|
export default SpecialEditorPage;
|
||||||
|
|
||||||
|
|
||||||
8
app/[locale]/curator/help/page.tsx
Normal file
8
app/[locale]/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorHelpInner from '../../../curator/help/page';
|
||||||
|
|
||||||
|
export default function CuratorHelpPage() {
|
||||||
|
return <CuratorHelpInner />;
|
||||||
|
}
|
||||||
|
|
||||||
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 />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorSpecialEditorPage from '@/app/curator/specials/[id]/page';
|
||||||
|
|
||||||
|
export default CuratorSpecialEditorPage;
|
||||||
|
|
||||||
|
|
||||||
9
app/[locale]/curator/specials/page.tsx
Normal file
9
app/[locale]/curator/specials/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorSpecialsClient from '@/app/curator/specials/CuratorSpecialsClient';
|
||||||
|
|
||||||
|
export default function CuratorSpecialsPage() {
|
||||||
|
return <CuratorSpecialsClient />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
app/[locale]/page.tsx
Normal file
161
app/[locale]/page.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
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({
|
||||||
|
where: { hidden: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
app/[locale]/special/[name]/page.tsx
Normal file
152
app/[locale]/special/[name]/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
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 => {
|
||||||
|
if (s.hidden) return false;
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,3 +80,30 @@ export async function submitRating(songId: number, rating: number, genre?: strin
|
|||||||
return { success: false, error: 'Failed to submit rating' };
|
return { success: false, error: 'Failed to submit rating' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendCommentNotification(puzzleId: number, message: string, originalMessage?: string, genre?: string | null) {
|
||||||
|
try {
|
||||||
|
const title = `New Curator Comment (Puzzle #${puzzleId})`;
|
||||||
|
let body = message;
|
||||||
|
|
||||||
|
if (originalMessage && originalMessage !== message) {
|
||||||
|
body = `Original: ${originalMessage}\n\nRewritten: ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (genre) {
|
||||||
|
body = `[${genre}] ${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title,
|
||||||
|
message: body,
|
||||||
|
priority: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending comment notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Hördle Admin Dashboard",
|
|
||||||
description: "Admin dashboard for managing songs and daily puzzles",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminLayout({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
2121
app/admin/page.tsx
2121
app/admin/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,102 +1,59 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||||
import WaveformEditor from '@/components/WaveformEditor';
|
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||||
|
|
||||||
interface Song {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpecialSong {
|
|
||||||
id: number;
|
|
||||||
songId: number;
|
|
||||||
startTime: number;
|
|
||||||
order: number | null;
|
|
||||||
song: Song;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Special {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
subtitle?: string;
|
|
||||||
maxAttempts: number;
|
|
||||||
unlockSteps: string;
|
|
||||||
songs: SpecialSong[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SpecialEditorPage() {
|
export default function SpecialEditorPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const specialId = params.id as string;
|
const specialId = params.id as string;
|
||||||
|
|
||||||
const [special, setSpecial] = useState<Special | null>(null);
|
// Locale aus dem Pfad ableiten (/en/..., /de/...)
|
||||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
|
const localeFromPath = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||||
|
const locale: 'de' | 'en' = localeFromPath === 'de' || localeFromPath === 'en' ? localeFromPath : 'en';
|
||||||
|
|
||||||
|
const [special, setSpecial] = useState<CurateSpecial | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchSpecial = async (showLoading = true) => {
|
||||||
fetchSpecial();
|
|
||||||
}, [specialId]);
|
|
||||||
|
|
||||||
const fetchSpecial = async () => {
|
|
||||||
try {
|
try {
|
||||||
|
if (showLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
const res = await fetch(`/api/specials/${specialId}`);
|
const res = await fetch(`/api/specials/${specialId}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSpecial(data);
|
setSpecial(data);
|
||||||
if (data.songs.length > 0) {
|
|
||||||
setSelectedSongId(data.songs[0].songId);
|
|
||||||
// Initialize pendingStartTime with the current startTime of the first song
|
|
||||||
setPendingStartTime(data.songs[0].startTime);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching special:', error);
|
console.error('Error fetching special:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (showLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartTimeChange = (newStartTime: number) => {
|
useEffect(() => {
|
||||||
setPendingStartTime(newStartTime);
|
fetchSpecial(true);
|
||||||
setHasUnsavedChanges(true);
|
}, [specialId]);
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||||
if (!special || !selectedSongId || pendingStartTime === null) return;
|
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ songId, startTime }),
|
||||||
|
});
|
||||||
|
|
||||||
setSaving(true);
|
if (!res.ok) {
|
||||||
try {
|
const errorText = await res.text().catch(() => res.statusText || 'Unknown error');
|
||||||
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
console.error('Error updating special song (admin):', res.status, errorText);
|
||||||
method: 'PUT',
|
throw new Error(`Failed to save start time: ${errorText}`);
|
||||||
headers: { 'Content-Type': 'application/json' },
|
} else {
|
||||||
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
|
// Reload special data to update the start time in the song list
|
||||||
});
|
await fetchSpecial(false);
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
// Update local state
|
|
||||||
setSpecial(prev => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
songs: prev.songs.map(ss =>
|
|
||||||
ss.songId === selectedSongId ? { ...ss, startTime: pendingStartTime } : ss
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
setPendingStartTime(null); // Reset pending state after saving
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating start time:', error);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,116 +74,16 @@ export default function SpecialEditorPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
|
|
||||||
const unlockSteps = JSON.parse(special.unlockSteps);
|
|
||||||
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
<CurateSpecialEditor
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
special={special}
|
||||||
<button
|
locale={locale}
|
||||||
onClick={() => router.push('/admin')}
|
onBack={() => router.push('/admin')}
|
||||||
style={{
|
onSaveStartTime={handleSaveStartTime}
|
||||||
padding: '0.5rem 1rem',
|
backLabel="← Back to Admin"
|
||||||
background: '#e5e7eb',
|
headerPrefix="Edit Special:"
|
||||||
border: 'none',
|
noSongsSubHint="Go back to the admin dashboard to add songs to this special."
|
||||||
borderRadius: '0.5rem',
|
/>
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '1rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
← Back to Admin
|
|
||||||
</button>
|
|
||||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
|
||||||
Edit Special: {special.name}
|
|
||||||
</h1>
|
|
||||||
{special.subtitle && (
|
|
||||||
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
|
|
||||||
{special.subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{special.songs.length === 0 ? (
|
|
||||||
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
|
||||||
<p>No songs assigned to this special yet.</p>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
Go back to the admin dashboard to add songs to this special.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Select Song to Curate
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
|
||||||
{special.songs.map(ss => (
|
|
||||||
<div
|
|
||||||
key={ss.songId}
|
|
||||||
onClick={() => setSelectedSongId(ss.songId)}
|
|
||||||
style={{
|
|
||||||
padding: '1rem',
|
|
||||||
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
|
||||||
color: selectedSongId === ss.songId ? 'white' : 'black',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
|
||||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
|
||||||
Start: {ss.startTime}s
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedSpecialSong && (
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Curate: {selectedSpecialSong.song.title}
|
|
||||||
</h2>
|
|
||||||
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
|
||||||
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!hasUnsavedChanges || saving}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1.5rem',
|
|
||||||
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
|
||||||
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? '💾 Saving...' : hasUnsavedChanges ? '💾 Save Changes' : '✓ Saved'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<WaveformEditor
|
|
||||||
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
|
||||||
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
|
||||||
duration={totalDuration}
|
|
||||||
unlockSteps={unlockSteps}
|
|
||||||
onStartTimeChange={handleStartTimeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,14 +98,14 @@ export async function DELETE(request: Request) {
|
|||||||
where: { id: puzzle.specialId }
|
where: { id: puzzle.specialId }
|
||||||
});
|
});
|
||||||
if (special) {
|
if (special) {
|
||||||
newPuzzle = await getOrCreateSpecialPuzzle(special.name);
|
newPuzzle = await getOrCreateSpecialPuzzle(special);
|
||||||
}
|
}
|
||||||
} else if (puzzle.genreId) {
|
} else if (puzzle.genreId) {
|
||||||
const genre = await prisma.genre.findUnique({
|
const genre = await prisma.genre.findUnique({
|
||||||
where: { id: puzzle.genreId }
|
where: { id: puzzle.genreId }
|
||||||
});
|
});
|
||||||
if (genre) {
|
if (genre) {
|
||||||
newPuzzle = await getOrCreateDailyPuzzle(genre.name);
|
newPuzzle = await getOrCreateDailyPuzzle(genre);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
newPuzzle = await getOrCreateDailyPuzzle(null);
|
newPuzzle = await getOrCreateDailyPuzzle(null);
|
||||||
|
|||||||
23
app/api/admin/reset-activations/route.ts
Normal file
23
app/api/admin/reset-activations/route.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Delete all daily puzzles (activations)
|
||||||
|
const result = await prisma.dailyPuzzle.deleteMany({});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully deleted ${result.count} daily puzzles (activations)`,
|
||||||
|
count: result.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting activations:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to reset activations' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/api/admin/reset-ratings/route.ts
Normal file
28
app/api/admin/reset-ratings/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Reset all song ratings to 0
|
||||||
|
const result = await prisma.song.updateMany({
|
||||||
|
data: {
|
||||||
|
averageRating: 0,
|
||||||
|
ratingCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully reset ratings for ${result.count} songs`,
|
||||||
|
count: result.count,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error resetting ratings:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to reset ratings' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { readFile, stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
@@ -30,24 +31,106 @@ export async function GET(
|
|||||||
return new NextResponse('Forbidden', { status: 403 });
|
return new NextResponse('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
const stats = await stat(filePath);
|
||||||
try {
|
const fileSize = stats.size;
|
||||||
await stat(filePath);
|
const range = request.headers.get('range');
|
||||||
} catch {
|
|
||||||
return new NextResponse('File not found', { status: 404 });
|
if (range) {
|
||||||
|
const parts = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||||
|
const chunksize = (end - start) + 1;
|
||||||
|
|
||||||
|
const stream = createReadStream(filePath, { start, end });
|
||||||
|
|
||||||
|
// Convert Node stream to Web stream
|
||||||
|
|
||||||
|
const readable = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
stream.on('data', (chunk: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
} catch (e) {
|
||||||
|
isClosed = true;
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (err: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(readable, {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': chunksize.toString(),
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const stream = createReadStream(filePath);
|
||||||
|
|
||||||
|
// Convert Node stream to Web stream
|
||||||
|
const readable = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
let isClosed = false;
|
||||||
|
|
||||||
|
stream.on('data', (chunk: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
} catch (e) {
|
||||||
|
isClosed = true;
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('end', () => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
stream.on('error', (err: any) => {
|
||||||
|
if (isClosed) return;
|
||||||
|
isClosed = true;
|
||||||
|
controller.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(readable, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Length': fileSize.toString(),
|
||||||
|
'Content-Type': 'audio/mpeg',
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read file
|
|
||||||
const fileBuffer = await readFile(filePath);
|
|
||||||
|
|
||||||
// Return with proper headers
|
|
||||||
return new NextResponse(fileBuffer, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'audio/mpeg',
|
|
||||||
'Accept-Ranges': 'bytes',
|
|
||||||
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error serving audio file:', error);
|
console.error('Error serving audio file:', error);
|
||||||
return new NextResponse('Internal Server Error', { status: 500 });
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -83,7 +84,8 @@ export async function POST(request: Request) {
|
|||||||
// Process each song in this batch
|
// Process each song in this batch
|
||||||
for (const song of uncategorizedSongs) {
|
for (const song of uncategorizedSongs) {
|
||||||
try {
|
try {
|
||||||
const genreNames = allGenres.map(g => g.name);
|
// Use German names for AI categorization (primary language)
|
||||||
|
const genreNames = allGenres.map(g => getLocalizedValue(g.name, 'de'));
|
||||||
|
|
||||||
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
|
const prompt = `You are a music genre categorization assistant. Given a song title and artist, categorize it into 0-3 of the available genres.
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ Your response:`;
|
|||||||
|
|
||||||
// Filter to only valid genres and get their IDs
|
// Filter to only valid genres and get their IDs
|
||||||
const genreIds = allGenres
|
const genreIds = allGenres
|
||||||
.filter(g => suggestedGenreNames.includes(g.name))
|
.filter(g => suggestedGenreNames.includes(getLocalizedValue(g.name, 'de')))
|
||||||
.map(g => g.id)
|
.map(g => g.id)
|
||||||
.slice(0, 3); // Max 3 genres
|
.slice(0, 3); // Max 3 genres
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ Your response:`;
|
|||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
assignedGenres: suggestedGenreNames.filter(name =>
|
assignedGenres: suggestedGenreNames.filter(name =>
|
||||||
allGenres.some(g => g.name === name)
|
allGenres.some(g => getLocalizedValue(g.name, 'de') === name)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
95
app/api/covers/[filename]/route.ts
Normal file
95
app/api/covers/[filename]/route.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { stat } from 'fs/promises';
|
||||||
|
import { createReadStream } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ filename: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { filename } = await params;
|
||||||
|
|
||||||
|
// Security: Prevent path traversal attacks
|
||||||
|
// Allow alphanumeric, hyphens, underscores, and dots for image filenames
|
||||||
|
// Support common image formats: jpg, jpeg, png, gif, webp
|
||||||
|
const safeFilenamePattern = /^[a-zA-Z0-9_\-\.]+\.(jpg|jpeg|png|gif|webp)$/i;
|
||||||
|
if (!safeFilenamePattern.test(filename)) {
|
||||||
|
return new NextResponse('Invalid filename', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional check: ensure no path separators
|
||||||
|
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
||||||
|
return new NextResponse('Invalid filename', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(process.cwd(), 'public/uploads/covers', filename);
|
||||||
|
|
||||||
|
// Security: Verify the resolved path is still within covers directory
|
||||||
|
const coversDir = path.join(process.cwd(), 'public/uploads/covers');
|
||||||
|
const resolvedPath = path.resolve(filePath);
|
||||||
|
if (!resolvedPath.startsWith(coversDir)) {
|
||||||
|
return new NextResponse('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await stat(filePath);
|
||||||
|
const fileSize = stats.size;
|
||||||
|
|
||||||
|
// Determine content type based on file extension
|
||||||
|
const ext = filename.toLowerCase().split('.').pop();
|
||||||
|
const contentTypeMap: Record<string, string> = {
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'webp': 'image/webp',
|
||||||
|
};
|
||||||
|
const contentType = contentTypeMap[ext || ''] || 'image/jpeg';
|
||||||
|
|
||||||
|
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': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=3600, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error serving cover image:', error);
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
207
app/api/curator-comment/route.ts
Normal file
207
app/api/curator-comment/route.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { rateLimit } from '@/lib/rateLimit';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// Rate limiting: 3 requests per minute
|
||||||
|
const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 3 });
|
||||||
|
if (rateLimitError) return rateLimitError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!puzzleId || !message || !playerIdentifier) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'puzzleId, message, and playerIdentifier are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message validation: max 2000 characters, no empty message
|
||||||
|
const trimmedMessage = message.trim();
|
||||||
|
if (trimmedMessage.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message cannot be empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (trimmedMessage.length > 300) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message too long. Maximum 300 characters allowed.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerIdentifier validation: Check if it exists in PlayerState
|
||||||
|
const playerState = await prisma.playerState.findFirst({
|
||||||
|
where: {
|
||||||
|
identifier: playerIdentifier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!playerState) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid player identifier' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puzzle validation: Check if puzzle exists and matches genreId
|
||||||
|
const puzzle = await prisma.dailyPuzzle.findUnique({
|
||||||
|
where: { id: Number(puzzleId) },
|
||||||
|
include: {
|
||||||
|
song: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!puzzle) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Puzzle not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate genreId matches puzzle (if genreId is provided)
|
||||||
|
if (genreId !== null && genreId !== undefined) {
|
||||||
|
if (puzzle.genreId !== Number(genreId)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Puzzle does not match the provided genre' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no genreId provided, use puzzle's genreId
|
||||||
|
// For global puzzles, genreId is null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check: Check if comment already exists for this playerIdentifier + puzzleId
|
||||||
|
const existingComment = await prisma.curatorComment.findUnique({
|
||||||
|
where: {
|
||||||
|
playerIdentifier_puzzleId: {
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
puzzleId: Number(puzzleId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingComment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'You have already sent a comment for this puzzle' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine responsible curators
|
||||||
|
const finalGenreId = genreId !== null && genreId !== undefined ? Number(genreId) : puzzle.genreId;
|
||||||
|
const specialId = puzzle.specialId;
|
||||||
|
|
||||||
|
let curatorIds: number[] = [];
|
||||||
|
const allCuratorIds = new Set<number>();
|
||||||
|
|
||||||
|
// Get all global curators (always included)
|
||||||
|
const globalCurators = await prisma.curator.findMany({
|
||||||
|
where: {
|
||||||
|
isGlobalCurator: true
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
globalCurators.forEach(gc => allCuratorIds.add(gc.id));
|
||||||
|
|
||||||
|
// Check for special puzzle first (takes precedence)
|
||||||
|
if (specialId !== null) {
|
||||||
|
// Special puzzle: Get curators for this special + all global curators
|
||||||
|
const specialCurators = await prisma.curatorSpecial.findMany({
|
||||||
|
where: {
|
||||||
|
specialId: specialId
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
curatorId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
specialCurators.forEach(cs => allCuratorIds.add(cs.curatorId));
|
||||||
|
} else if (finalGenreId !== null) {
|
||||||
|
// Genre puzzle: Get curators for this genre + all global curators
|
||||||
|
const genreCurators = await prisma.curatorGenre.findMany({
|
||||||
|
where: {
|
||||||
|
genreId: finalGenreId
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
curatorId: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId));
|
||||||
|
}
|
||||||
|
// else: Global puzzle - only global curators (already added above)
|
||||||
|
|
||||||
|
curatorIds = Array.from(allCuratorIds);
|
||||||
|
|
||||||
|
if (curatorIds.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No curators found for this puzzle' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create comment and recipients in a transaction
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
// Create the comment
|
||||||
|
const comment = await tx.curatorComment.create({
|
||||||
|
data: {
|
||||||
|
playerIdentifier: playerIdentifier,
|
||||||
|
puzzleId: Number(puzzleId),
|
||||||
|
genreId: finalGenreId,
|
||||||
|
message: trimmedMessage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create recipients for all curators
|
||||||
|
await tx.curatorCommentRecipient.createMany({
|
||||||
|
data: curatorIds.map(curatorId => ({
|
||||||
|
commentId: comment.id,
|
||||||
|
curatorId: curatorId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send Gotify notification (fire and forget)
|
||||||
|
const { sendCommentNotification } = await import('@/app/actions');
|
||||||
|
// originalMessage is already available from the initial request.json() call
|
||||||
|
|
||||||
|
// Determine genre name for notification
|
||||||
|
let genreName: string | null = null;
|
||||||
|
if (finalGenreId) {
|
||||||
|
const genreObj = await prisma.genre.findUnique({ where: { id: finalGenreId } });
|
||||||
|
if (genreObj) genreName = genreObj.name as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCommentNotification(Number(puzzleId), trimmedMessage, originalMessage, genreName || null);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
commentId: result.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating curator comment:', error);
|
||||||
|
|
||||||
|
// Handle unique constraint violation (shouldn't happen due to our check, but just in case)
|
||||||
|
if (error instanceof Error && error.message.includes('Unique constraint')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'You have already sent a comment for this puzzle' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/curator-comments/[id]/archive/route.ts
Normal file
66
app/api/curator-comments/[id]/archive/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can archive comments
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can archive comments' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const commentId = Number(id);
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Verify that this comment belongs to this curator
|
||||||
|
const recipient = await prisma.curatorCommentRecipient.findUnique({
|
||||||
|
where: {
|
||||||
|
commentId_curatorId: {
|
||||||
|
commentId: commentId,
|
||||||
|
curatorId: curatorId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Comment not found or access denied' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update archived flag
|
||||||
|
await prisma.curatorCommentRecipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
archived: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error archiving comment:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
app/api/curator-comments/[id]/read/route.ts
Normal file
66
app/api/curator-comments/[id]/read/route.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can mark comments as read
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can mark comments as read' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const commentId = Number(id);
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Verify that this comment belongs to this curator
|
||||||
|
const recipient = await prisma.curatorCommentRecipient.findUnique({
|
||||||
|
where: {
|
||||||
|
commentId_curatorId: {
|
||||||
|
commentId: commentId,
|
||||||
|
curatorId: curatorId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Comment not found or access denied' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update readAt timestamp
|
||||||
|
await prisma.curatorCommentRecipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
readAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking comment as read:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
139
app/api/curator-comments/route.ts
Normal file
139
app/api/curator-comments/route.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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) {
|
||||||
|
// Require curator authentication
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only curators can view comments (not admins directly)
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can view comments' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const curatorId = context.curator.id;
|
||||||
|
|
||||||
|
// Get all non-archived comments for this curator, ordered by creation date (newest first)
|
||||||
|
const comments = await prisma.curatorCommentRecipient.findMany({
|
||||||
|
where: {
|
||||||
|
curatorId: curatorId,
|
||||||
|
archived: false
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
comment: {
|
||||||
|
include: {
|
||||||
|
puzzle: {
|
||||||
|
include: {
|
||||||
|
song: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
artist: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
genre: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
special: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
comment: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format the response with puzzle context
|
||||||
|
const formattedComments = await Promise.all(comments.map(async (recipient) => {
|
||||||
|
const puzzle = recipient.comment.puzzle;
|
||||||
|
|
||||||
|
// Calculate puzzle number
|
||||||
|
let puzzleNumber = 0;
|
||||||
|
if (puzzle.specialId) {
|
||||||
|
// Special puzzle
|
||||||
|
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||||
|
where: {
|
||||||
|
specialId: puzzle.specialId,
|
||||||
|
date: {
|
||||||
|
lte: puzzle.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (puzzle.genreId) {
|
||||||
|
// Genre puzzle
|
||||||
|
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||||
|
where: {
|
||||||
|
genreId: puzzle.genreId,
|
||||||
|
date: {
|
||||||
|
lte: puzzle.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Global puzzle
|
||||||
|
puzzleNumber = await prisma.dailyPuzzle.count({
|
||||||
|
where: {
|
||||||
|
genreId: null,
|
||||||
|
specialId: null,
|
||||||
|
date: {
|
||||||
|
lte: puzzle.date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: recipient.comment.id,
|
||||||
|
message: recipient.comment.message,
|
||||||
|
createdAt: recipient.comment.createdAt,
|
||||||
|
readAt: recipient.readAt,
|
||||||
|
puzzle: {
|
||||||
|
id: puzzle.id,
|
||||||
|
date: puzzle.date,
|
||||||
|
puzzleNumber: puzzleNumber,
|
||||||
|
song: {
|
||||||
|
title: puzzle.song.title,
|
||||||
|
artist: puzzle.song.artist
|
||||||
|
},
|
||||||
|
genre: puzzle.genre ? {
|
||||||
|
id: puzzle.genre.id,
|
||||||
|
name: puzzle.genre.name
|
||||||
|
} : null,
|
||||||
|
special: puzzle.special ? {
|
||||||
|
id: puzzle.special.id,
|
||||||
|
name: puzzle.special.name
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(formattedComments);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching curator comments:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
97
app/api/curator/specials/[id]/route.ts
Normal file
97
app/api/curator/specials/[id]/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
import { access } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Mark route as dynamic to prevent caching
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
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 { id } = await params;
|
||||||
|
const specialId = Number(id);
|
||||||
|
if (!specialId || Number.isNaN(specialId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
|
||||||
|
const assignment = await prisma.curatorSpecial.findFirst({
|
||||||
|
where: { curatorId: context.curator.id, specialId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: You are not allowed to access this special' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const special = await prisma.special.findUnique({
|
||||||
|
where: { id: specialId },
|
||||||
|
include: {
|
||||||
|
songs: {
|
||||||
|
include: {
|
||||||
|
song: true,
|
||||||
|
},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!special) {
|
||||||
|
return NextResponse.json({ error: 'Special not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtere Songs ohne vollständige Song-Daten und prüfe Datei-Existenz
|
||||||
|
// Dies verhindert Fehler im Frontend, wenn Songs gelöscht wurden, Daten fehlen
|
||||||
|
// oder Dateien noch nicht im Container verfügbar sind (Volume Mount Delay)
|
||||||
|
const uploadsDir = path.join(process.cwd(), 'public/uploads');
|
||||||
|
|
||||||
|
const filteredSongs = await Promise.all(
|
||||||
|
special.songs
|
||||||
|
.filter(ss => ss.song && ss.song.filename)
|
||||||
|
.map(async (ss) => {
|
||||||
|
const filePath = path.join(uploadsDir, ss.song.filename);
|
||||||
|
try {
|
||||||
|
// Prüfe ob Datei existiert und zugänglich ist
|
||||||
|
await access(filePath);
|
||||||
|
return ss;
|
||||||
|
} catch (error) {
|
||||||
|
// Datei existiert nicht oder ist nicht zugänglich
|
||||||
|
console.warn(`[API] Song file not available: ${ss.song.filename} (may be syncing)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Entferne null-Werte (Songs ohne verfügbare Dateien)
|
||||||
|
const availableSongs = filteredSongs.filter((ss): ss is typeof special.songs[0] => ss !== null);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...special,
|
||||||
|
songs: availableSongs,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
69
app/api/curator/specials/[id]/songs/route.ts
Normal file
69
app/api/curator/specials/[id]/songs/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = Number(id);
|
||||||
|
const { songId, startTime, order } = await request.json();
|
||||||
|
|
||||||
|
if (!specialId || Number.isNaN(specialId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!songId || typeof startTime !== 'number') {
|
||||||
|
return NextResponse.json({ error: 'Missing songId or startTime' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
|
||||||
|
const assignment = await prisma.curatorSpecial.findFirst({
|
||||||
|
where: { curatorId: context.curator.id, specialId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: You are not allowed to edit this special' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialSong = await prisma.specialSong.update({
|
||||||
|
where: {
|
||||||
|
specialId_songId: {
|
||||||
|
specialId,
|
||||||
|
songId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
startTime,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
song: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(specialSong);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating curator special song:', e);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
47
app/api/curator/specials/route.ts
Normal file
47
app/api/curator/specials/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specials, die diesem Kurator zugewiesen sind
|
||||||
|
const assignments = await prisma.curatorSpecial.findMany({
|
||||||
|
where: { curatorId: context.curator.id },
|
||||||
|
select: { specialId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialIds = assignments.map(a => a.specialId);
|
||||||
|
|
||||||
|
const specials = await prisma.special.findMany({
|
||||||
|
where: { id: { in: specialIds } },
|
||||||
|
include: {
|
||||||
|
songs: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = specials.map(special => ({
|
||||||
|
id: special.id,
|
||||||
|
name: special.name,
|
||||||
|
songCount: special.songs.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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 { NextResponse } from 'next/server';
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const genreName = searchParams.get('genre');
|
const genreName = searchParams.get('genre');
|
||||||
|
|
||||||
const puzzle = await getOrCreateDailyPuzzle(genreName);
|
let genre = null;
|
||||||
|
if (genreName) {
|
||||||
|
// Find genre by localized name (try both locales)
|
||||||
|
const allGenres = await prisma.genre.findMany();
|
||||||
|
genre = allGenres.find(g =>
|
||||||
|
getLocalizedValue(g.name, 'de') === genreName ||
|
||||||
|
getLocalizedValue(g.name, 'en') === genreName
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const puzzle = await getOrCreateDailyPuzzle(genre);
|
||||||
|
|
||||||
if (!puzzle) {
|
if (!puzzle) {
|
||||||
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
return NextResponse.json({ error: 'Failed to get or create puzzle' }, { status: 404 });
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const genres = await prisma.genre.findMany({
|
const genres = await prisma.genre.findMany({
|
||||||
orderBy: { name: 'asc' },
|
// orderBy: { name: 'asc' }, // Cannot sort by JSON field easily in SQLite
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { songs: true }
|
select: { songs: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sort in memory if needed, or just return
|
||||||
|
// If locale is provided, map to localized values
|
||||||
|
if (locale) {
|
||||||
|
const localizedGenres = genres.map(g => ({
|
||||||
|
...g,
|
||||||
|
name: getLocalizedValue(g.name, locale),
|
||||||
|
subtitle: getLocalizedValue(g.subtitle, locale)
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return NextResponse.json(localizedGenres);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(genres);
|
return NextResponse.json(genres);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching genres:', error);
|
console.error('Error fetching genres:', error);
|
||||||
@@ -29,14 +45,18 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { name, subtitle, active } = await request.json();
|
const { name, subtitle, active } = await request.json();
|
||||||
|
|
||||||
if (!name || typeof name !== 'string') {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid name' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure name is stored as JSON
|
||||||
|
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
|
||||||
|
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||||
|
|
||||||
const genre = await prisma.genre.create({
|
const genre = await prisma.genre.create({
|
||||||
data: {
|
data: {
|
||||||
name: name.trim(),
|
name: nameData,
|
||||||
subtitle: subtitle ? subtitle.trim() : null,
|
subtitle: subtitleData,
|
||||||
active: active !== undefined ? active : true
|
active: active !== undefined ? active : true
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -83,13 +103,14 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (name) updateData.name = typeof name === 'string' ? { de: name, en: name } : name;
|
||||||
|
if (subtitle !== undefined) updateData.subtitle = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||||
|
if (active !== undefined) updateData.active = active;
|
||||||
|
|
||||||
const genre = await prisma.genre.update({
|
const genre = await prisma.genre.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: updateData,
|
||||||
...(name && { name: name.trim() }),
|
|
||||||
subtitle: subtitle ? subtitle.trim() : null,
|
|
||||||
...(active !== undefined && { active })
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(genre);
|
return NextResponse.json(genre);
|
||||||
|
|||||||
5
app/api/health/route.ts
Normal file
5
app/api/health/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({ status: 'ok' }, { status: 200 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export async function GET(request: Request) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const limit = parseInt(searchParams.get('limit') || '10');
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
const featuredOnly = searchParams.get('featured') === 'true';
|
const featuredOnly = searchParams.get('featured') === 'true';
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const where = featuredOnly ? { featured: true } : {};
|
const where = featuredOnly ? { featured: true } : {};
|
||||||
|
|
||||||
@@ -27,6 +29,19 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (locale) {
|
||||||
|
const localizedNews = news.map(item => ({
|
||||||
|
...item,
|
||||||
|
title: getLocalizedValue(item.title, locale),
|
||||||
|
content: getLocalizedValue(item.content, locale),
|
||||||
|
special: item.special ? {
|
||||||
|
...item.special,
|
||||||
|
name: getLocalizedValue(item.special.name, locale)
|
||||||
|
} : null
|
||||||
|
}));
|
||||||
|
return NextResponse.json(localizedNews);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(news);
|
return NextResponse.json(news);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching news:', error);
|
console.error('Error fetching news:', error);
|
||||||
@@ -52,10 +67,14 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure title and content are stored as JSON
|
||||||
|
const titleData = typeof title === 'string' ? { de: title, en: title } : title;
|
||||||
|
const contentData = typeof content === 'string' ? { de: content, en: content } : content;
|
||||||
|
|
||||||
const news = await prisma.news.create({
|
const news = await prisma.news.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title: titleData,
|
||||||
content,
|
content: contentData,
|
||||||
author: author || null,
|
author: author || null,
|
||||||
featured: featured || false,
|
featured: featured || false,
|
||||||
specialId: specialId || null
|
specialId: specialId || null
|
||||||
@@ -93,8 +112,8 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: any = {};
|
||||||
if (title !== undefined) updateData.title = title;
|
if (title !== undefined) updateData.title = typeof title === 'string' ? { de: title, en: title } : title;
|
||||||
if (content !== undefined) updateData.content = content;
|
if (content !== undefined) updateData.content = typeof content === 'string' ? { de: content, en: content } : content;
|
||||||
if (author !== undefined) updateData.author = author || null;
|
if (author !== undefined) updateData.author = author || null;
|
||||||
if (featured !== undefined) updateData.featured = featured;
|
if (featured !== undefined) updateData.featured = featured;
|
||||||
if (specialId !== undefined) updateData.specialId = specialId || null;
|
if (specialId !== undefined) updateData.specialId = specialId || null;
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
107
app/api/rewrite-message/route.ts
Normal file
107
app/api/rewrite-message/route.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||||
|
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { message } = await request.json();
|
||||||
|
|
||||||
|
if (!message || typeof message !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message is required and must be a string' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OPENROUTER_API_KEY) {
|
||||||
|
console.error('OPENROUTER_API_KEY is not configured');
|
||||||
|
// Fallback: return original message if API key is missing
|
||||||
|
return NextResponse.json({ rewrittenMessage: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `You are a content moderation assistant. Analyze the following message and determine if it is truly inappropriate, unfriendly, sexist, or offensive.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- ONLY rewrite the message if it is genuinely unfriendly, sexist, inappropriate, or offensive
|
||||||
|
- If the message is polite, constructive, or even just neutral/critical feedback, return it UNCHANGED
|
||||||
|
- If the message needs rewriting, rewrite it to express the COMPLETE OPPOSITE meaning - make it positive, respectful, and appreciative
|
||||||
|
- Maintain the original language (German or English)
|
||||||
|
- Return ONLY the message text (either unchanged original or rewritten version), nothing else
|
||||||
|
|
||||||
|
Message: "${message}"`;
|
||||||
|
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://hoerdle.elpatron.me',
|
||||||
|
'X-Title': 'Hördle Message Rewriter'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OPENROUTER_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 500
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('OpenRouter API error:', await response.text());
|
||||||
|
// Fallback: return original message
|
||||||
|
return NextResponse.json({ rewrittenMessage: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
|
||||||
|
|
||||||
|
// Remove any explanatory comments in parentheses that the AI might add
|
||||||
|
// e.g., "(This message is a friendly, positive comment expressing appreciation. No rewriting is necessary.)"
|
||||||
|
rewrittenMessage = rewrittenMessage.replace(/\s*\([^)]*\)\s*/g, '').trim();
|
||||||
|
|
||||||
|
// Remove surrounding quotes if present (AI sometimes adds quotes)
|
||||||
|
// Handle both single and double quotes, and multiple layers of quotes
|
||||||
|
rewrittenMessage = rewrittenMessage.replace(/^["']+|["']+$/g, '').trim();
|
||||||
|
|
||||||
|
// Normalize both messages for comparison (remove extra whitespace, normalize quotes, case-insensitive)
|
||||||
|
const normalizeForComparison = (text: string): string => {
|
||||||
|
return text
|
||||||
|
.trim()
|
||||||
|
.replace(/["']/g, '') // Remove all quotes for comparison
|
||||||
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[.,!?;:]\s*$/, ''); // Remove trailing punctuation for comparison
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalTrimmed = message.trim();
|
||||||
|
const rewrittenTrimmed = rewrittenMessage.trim();
|
||||||
|
const originalNormalized = normalizeForComparison(originalTrimmed);
|
||||||
|
const rewrittenNormalized = normalizeForComparison(rewrittenTrimmed);
|
||||||
|
|
||||||
|
// Check if message was actually changed (content-wise, not just formatting)
|
||||||
|
// Only consider it changed if the normalized content is different
|
||||||
|
const wasChanged = originalNormalized !== rewrittenNormalized;
|
||||||
|
|
||||||
|
if (wasChanged) {
|
||||||
|
rewrittenMessage = rewrittenTrimmed + " (autocorrected by Polite-Bot)";
|
||||||
|
} else {
|
||||||
|
// Return original message if not changed (without suffix)
|
||||||
|
rewrittenMessage = originalTrimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ rewrittenMessage });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rewriting message:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
266
app/api/songs/batch/route.ts
Normal file
266
app/api/songs/batch/route.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { 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);
|
||||||
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json();
|
||||||
|
|
||||||
|
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that at least one operation is requested
|
||||||
|
const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0;
|
||||||
|
const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0;
|
||||||
|
const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== '';
|
||||||
|
const hasExcludeGlobalChange = excludeFromGlobal !== undefined;
|
||||||
|
|
||||||
|
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
|
||||||
|
return NextResponse.json({ error: 'No update operations specified' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate artist if provided
|
||||||
|
if (hasArtistChange && artist.trim() === '') {
|
||||||
|
return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate excludeFromGlobal permission
|
||||||
|
if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const curatorAssignments = await getCuratorAssignments(context.curator.id);
|
||||||
|
assignments = curatorAssignments;
|
||||||
|
|
||||||
|
// Validate genre/special toggles are within curator's assignments
|
||||||
|
if (hasGenreToggle) {
|
||||||
|
const invalidGenre = genreToggleIds.some((id: number) => !curatorAssignments.genreIds.has(id));
|
||||||
|
if (invalidGenre) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only toggle their own genres' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSpecialToggle) {
|
||||||
|
const invalidSpecial = specialToggleIds.some((id: number) => !curatorAssignments.specialIds.has(id));
|
||||||
|
if (invalidSpecial) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Curators may only toggle their own specials' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all songs with relations for permission checks
|
||||||
|
const songs = await prisma.song.findMany({
|
||||||
|
where: { id: { in: songIds.map((id: any) => Number(id)) } },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (songs.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No songs found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter songs that can be edited
|
||||||
|
const editableSongs = context.role === 'admin'
|
||||||
|
? songs
|
||||||
|
: songs.filter(song => curatorCanEditSong(context, song, assignments!));
|
||||||
|
|
||||||
|
if (editableSongs.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No songs can be edited with current permissions' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
total: songIds.length,
|
||||||
|
processed: editableSongs.length,
|
||||||
|
skipped: songs.length - editableSongs.length,
|
||||||
|
success: 0,
|
||||||
|
errors: [] as Array<{ songId: number; error: string }>,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Process each song in a transaction
|
||||||
|
for (const song of editableSongs) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const updateData: any = {};
|
||||||
|
|
||||||
|
// Handle artist change
|
||||||
|
if (hasArtistChange) {
|
||||||
|
updateData.artist = artist.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle excludeFromGlobal change
|
||||||
|
if (hasExcludeGlobalChange) {
|
||||||
|
updateData.excludeFromGlobal = excludeFromGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle genre toggles
|
||||||
|
if (hasGenreToggle) {
|
||||||
|
const currentGenreIds = song.genres.map(g => g.id);
|
||||||
|
const genreIdsToToggle = genreToggleIds as number[];
|
||||||
|
|
||||||
|
// Determine which genres to add/remove
|
||||||
|
const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id));
|
||||||
|
const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id));
|
||||||
|
|
||||||
|
// For curators, preserve genres they can't manage
|
||||||
|
let finalGenreIds: number[];
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid));
|
||||||
|
const managedGenreIds = currentGenreIds
|
||||||
|
.filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid))
|
||||||
|
.concat(genresToAdd);
|
||||||
|
finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
|
||||||
|
} else {
|
||||||
|
const newGenreIds = currentGenreIds
|
||||||
|
.filter(id => !genresToRemove.includes(id))
|
||||||
|
.concat(genresToAdd);
|
||||||
|
finalGenreIds = Array.from(new Set(newGenreIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.genres = {
|
||||||
|
set: finalGenreIds.map(gId => ({ id: gId }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update song basic data
|
||||||
|
if (Object.keys(updateData).length > 0) {
|
||||||
|
await tx.song.update({
|
||||||
|
where: { id: song.id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle special toggles
|
||||||
|
if (hasSpecialToggle) {
|
||||||
|
const currentSpecials = await tx.specialSong.findMany({
|
||||||
|
where: { songId: song.id }
|
||||||
|
});
|
||||||
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
|
const specialIdsToToggle = specialToggleIds as number[];
|
||||||
|
|
||||||
|
// Determine which specials to add/remove
|
||||||
|
const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id));
|
||||||
|
const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id));
|
||||||
|
|
||||||
|
// For curators, preserve specials they can't manage
|
||||||
|
let finalSpecialIds: number[];
|
||||||
|
if (context.role === 'curator') {
|
||||||
|
const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid));
|
||||||
|
const managedSpecialIds = currentSpecialIds
|
||||||
|
.filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid))
|
||||||
|
.concat(specialsToAdd);
|
||||||
|
finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
|
||||||
|
} else {
|
||||||
|
const newSpecialIds = currentSpecialIds
|
||||||
|
.filter(id => !specialsToRemove.includes(id))
|
||||||
|
.concat(specialsToAdd);
|
||||||
|
finalSpecialIds = Array.from(new Set(newSpecialIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removed specials
|
||||||
|
const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid));
|
||||||
|
if (toDelete.length > 0) {
|
||||||
|
await tx.specialSong.deleteMany({
|
||||||
|
where: {
|
||||||
|
songId: song.id,
|
||||||
|
specialId: { in: toDelete }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new specials
|
||||||
|
const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
await tx.specialSong.createMany({
|
||||||
|
data: toAdd.map(specialId => ({
|
||||||
|
songId: song.id,
|
||||||
|
specialId,
|
||||||
|
startTime: 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
results.success++;
|
||||||
|
} catch (error: any) {
|
||||||
|
results.errors.push({
|
||||||
|
songId: song.id,
|
||||||
|
error: error.message || 'Unknown error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in batch update:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,96 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { writeFile, unlink } from 'fs/promises';
|
import { writeFile, unlink } from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { parseBuffer } from 'music-metadata';
|
import { parseBuffer } from 'music-metadata';
|
||||||
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
import { isDuplicateSong } from '@/lib/fuzzyMatch';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { getStaffContext, requireStaffAuth, StaffContext } from '@/lib/auth';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
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.
|
||||||
|
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||||
|
// Daher zuerst specialId oder special.id prüfen.
|
||||||
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.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);
|
||||||
|
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||||
|
// Daher zuerst specialId oder special.id prüfen.
|
||||||
|
const songSpecialIds = (song.specials || [])
|
||||||
|
.map((s: any) => {
|
||||||
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
|
if (s?.specialId != null) return s.specialId;
|
||||||
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.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
|
// Configure route to handle large file uploads
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const maxDuration = 60; // 60 seconds timeout for uploads
|
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({
|
const songs = await prisma.song.findMany({
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
@@ -26,8 +104,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
|
// Map to include activation count and flatten specials
|
||||||
const songsWithActivations = songs.map(song => ({
|
const songsWithActivations = visibleSongs.map(song => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.title,
|
title: song.title,
|
||||||
artist: song.artist,
|
artist: song.artist,
|
||||||
@@ -38,7 +141,10 @@ export async function GET() {
|
|||||||
activations: song.puzzles.length,
|
activations: song.puzzles.length,
|
||||||
puzzles: song.puzzles,
|
puzzles: song.puzzles,
|
||||||
genres: song.genres,
|
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,
|
averageRating: song.averageRating,
|
||||||
ratingCount: song.ratingCount,
|
ratingCount: song.ratingCount,
|
||||||
excludeFromGlobal: song.excludeFromGlobal,
|
excludeFromGlobal: song.excludeFromGlobal,
|
||||||
@@ -50,11 +156,11 @@ export async function GET() {
|
|||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
console.log('[UPLOAD] Starting song upload request');
|
console.log('[UPLOAD] Starting song upload request');
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) {
|
if (error || !context) {
|
||||||
console.log('[UPLOAD] Authentication failed');
|
console.log('[UPLOAD] Authentication failed');
|
||||||
return authError;
|
return error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -63,10 +169,17 @@ export async function POST(request: Request) {
|
|||||||
const file = formData.get('file') as File;
|
const file = formData.get('file') as File;
|
||||||
let title = '';
|
let title = '';
|
||||||
let artist = '';
|
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] 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) {
|
if (!file) {
|
||||||
console.error('[UPLOAD] No file provided');
|
console.error('[UPLOAD] No file provided');
|
||||||
@@ -101,6 +214,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// Validate and extract metadata from file
|
// Validate and extract metadata from file
|
||||||
let metadata;
|
let metadata;
|
||||||
|
let releaseYear: number | null = null;
|
||||||
let validationInfo = {
|
let validationInfo = {
|
||||||
isValid: true,
|
isValid: true,
|
||||||
hasCover: false,
|
hasCover: false,
|
||||||
@@ -131,6 +245,11 @@ export async function POST(request: Request) {
|
|||||||
artist = metadata.common.albumartist;
|
artist = metadata.common.albumartist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to extract release year from tags (preferred over external APIs)
|
||||||
|
if (typeof metadata.common.year === 'number') {
|
||||||
|
releaseYear = metadata.common.year;
|
||||||
|
}
|
||||||
|
|
||||||
// Validation info
|
// Validation info
|
||||||
validationInfo.hasCover = !!metadata.common.picture?.[0];
|
validationInfo.hasCover = !!metadata.common.picture?.[0];
|
||||||
validationInfo.format = metadata.format.container || 'unknown';
|
validationInfo.format = metadata.format.container || 'unknown';
|
||||||
@@ -225,17 +344,19 @@ export async function POST(request: Request) {
|
|||||||
console.error('Failed to extract cover image:', e);
|
console.error('Failed to extract cover image:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch release year from iTunes
|
// Fetch release year from iTunes only if not already present from tags
|
||||||
let releaseYear = null;
|
if (releaseYear == null) {
|
||||||
try {
|
try {
|
||||||
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
const { getReleaseYearFromItunes } = await import('@/lib/itunes');
|
||||||
releaseYear = await getReleaseYearFromItunes(artist, title);
|
const fetchedYear = await getReleaseYearFromItunes(artist, title);
|
||||||
|
|
||||||
if (releaseYear) {
|
if (fetchedYear) {
|
||||||
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
releaseYear = fetchedYear;
|
||||||
|
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch release year:', e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch release year:', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const song = await prisma.song.create({
|
const song = await prisma.song.create({
|
||||||
@@ -261,9 +382,9 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(request: Request) {
|
export async function PUT(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
|
||||||
@@ -272,6 +393,73 @@ export async function PUT(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
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: {
|
||||||
|
include: {
|
||||||
|
special: 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 };
|
const data: any = { title, artist };
|
||||||
|
|
||||||
// Update releaseYear if provided (can be null to clear it)
|
// Update releaseYear if provided (can be null to clear it)
|
||||||
@@ -280,60 +468,76 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (excludeFromGlobal !== undefined) {
|
if (excludeFromGlobal !== undefined) {
|
||||||
data.excludeFromGlobal = excludeFromGlobal;
|
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 = {
|
data.genres = {
|
||||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SpecialSong relations separately
|
// Execute all database write operations in a transaction to ensure consistency
|
||||||
if (specialIds !== undefined) {
|
const updatedSong = await prisma.$transaction(async (tx) => {
|
||||||
// First, get current special assignments
|
// Handle SpecialSong relations separately
|
||||||
const currentSpecials = await prisma.specialSong.findMany({
|
if (effectiveSpecialIds !== undefined) {
|
||||||
where: { songId: Number(id) }
|
// First, get current special assignments (within transaction)
|
||||||
});
|
const currentSpecials = await tx.specialSong.findMany({
|
||||||
|
where: { songId: Number(id) }
|
||||||
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
|
||||||
const newSpecialIds = specialIds as number[];
|
|
||||||
|
|
||||||
// Delete removed specials
|
|
||||||
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
|
||||||
if (toDelete.length > 0) {
|
|
||||||
await prisma.specialSong.deleteMany({
|
|
||||||
where: {
|
|
||||||
songId: Number(id),
|
|
||||||
specialId: { in: toDelete }
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Add new specials
|
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
|
||||||
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
const newSpecialIds = effectiveSpecialIds as number[];
|
||||||
if (toAdd.length > 0) {
|
|
||||||
await prisma.specialSong.createMany({
|
|
||||||
data: toAdd.map(specialId => ({
|
|
||||||
songId: Number(id),
|
|
||||||
specialId,
|
|
||||||
startTime: 0
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSong = await prisma.song.update({
|
// Delete removed specials
|
||||||
where: { id: Number(id) },
|
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
|
||||||
data,
|
if (toDelete.length > 0) {
|
||||||
include: {
|
await tx.specialSong.deleteMany({
|
||||||
genres: true,
|
where: {
|
||||||
specials: {
|
songId: Number(id),
|
||||||
include: {
|
specialId: { in: toDelete }
|
||||||
special: true
|
}
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new specials
|
||||||
|
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
await tx.specialSong.createMany({
|
||||||
|
data: toAdd.map(specialId => ({
|
||||||
|
songId: Number(id),
|
||||||
|
specialId,
|
||||||
|
startTime: 0
|
||||||
|
}))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update song (this also handles genre relations via Prisma's set operation)
|
||||||
|
return await tx.song.update({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(updatedSong);
|
return NextResponse.json(updatedSong);
|
||||||
@@ -344,9 +548,9 @@ export async function PUT(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: Request) {
|
export async function DELETE(request: Request) {
|
||||||
// Check authentication
|
// Check authentication (admin or curator)
|
||||||
const authError = await requireAdminAuth(request as any);
|
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
|
||||||
if (authError) return authError;
|
if (error || !context) return error!;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await request.json();
|
const { id } = await request.json();
|
||||||
@@ -355,16 +559,31 @@ export async function DELETE(request: Request) {
|
|||||||
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
|
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({
|
const song = await prisma.song.findUnique({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
|
include: {
|
||||||
|
genres: true,
|
||||||
|
specials: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!song) {
|
if (!song) {
|
||||||
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete file
|
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 files first (outside transaction, as file system operations can't be rolled back)
|
||||||
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
|
||||||
try {
|
try {
|
||||||
await unlink(filePath);
|
await unlink(filePath);
|
||||||
@@ -383,9 +602,11 @@ export async function DELETE(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database (will cascade delete related puzzles)
|
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
|
||||||
await prisma.song.delete({
|
await prisma.$transaction(async (tx) => {
|
||||||
where: { id: Number(id) },
|
await tx.song.delete({
|
||||||
|
where: { id: Number(id) },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
|||||||
@@ -43,18 +43,20 @@ export async function PUT(
|
|||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const specialId = parseInt(id);
|
const specialId = parseInt(id);
|
||||||
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
|
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (name !== undefined) updateData.name = name;
|
||||||
|
if (maxAttempts !== undefined) updateData.maxAttempts = maxAttempts;
|
||||||
|
if (unlockSteps !== undefined) updateData.unlockSteps = typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(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;
|
||||||
|
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
|
||||||
|
|
||||||
const special = await prisma.special.update({
|
const special = await prisma.special.update({
|
||||||
where: { id: specialId },
|
where: { id: specialId },
|
||||||
data: {
|
data: updateData
|
||||||
name,
|
|
||||||
maxAttempts,
|
|
||||||
unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps),
|
|
||||||
launchDate: launchDate ? new Date(launchDate) : null,
|
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
|
||||||
curator: curator || null,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(special);
|
return NextResponse.json(special);
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
import { PrismaClient, Special } from '@prisma/client';
|
import { PrismaClient, Special } from '@prisma/client';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { requireAdminAuth } from '@/lib/auth';
|
import { requireAdminAuth } from '@/lib/auth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale');
|
||||||
|
|
||||||
const specials = await prisma.special.findMany({
|
const specials = await prisma.special.findMany({
|
||||||
orderBy: { name: 'asc' },
|
// orderBy: { name: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { songs: true }
|
select: { songs: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (locale) {
|
||||||
|
const localizedSpecials = specials.map(s => ({
|
||||||
|
...s,
|
||||||
|
name: getLocalizedValue(s.name, locale),
|
||||||
|
subtitle: getLocalizedValue(s.subtitle, locale)
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return NextResponse.json(localizedSpecials);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json(specials);
|
return NextResponse.json(specials);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,19 +35,40 @@ export async function POST(request: Request) {
|
|||||||
const authError = await requireAdminAuth(request as any);
|
const authError = await requireAdminAuth(request as any);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
|
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator, hidden = false } = await request.json();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate unlockSteps JSON
|
||||||
|
if (unlockSteps) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(unlockSteps);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return NextResponse.json({ error: 'Unlock steps must be a JSON array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (parsed.some((item: any) => typeof item !== 'number' || item < 1)) {
|
||||||
|
return NextResponse.json({ error: 'All unlock step values must be positive numbers' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON format for unlock steps. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure name is stored as JSON
|
||||||
|
const nameData = typeof name === 'string' ? { de: name, en: name } : name;
|
||||||
|
const subtitleData = subtitle ? (typeof subtitle === 'string' ? { de: subtitle, en: subtitle } : subtitle) : null;
|
||||||
|
|
||||||
const special = await prisma.special.create({
|
const special = await prisma.special.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name: nameData,
|
||||||
subtitle: subtitle || null,
|
subtitle: subtitleData,
|
||||||
maxAttempts: Number(maxAttempts),
|
maxAttempts: Number(maxAttempts),
|
||||||
unlockSteps,
|
unlockSteps,
|
||||||
launchDate: launchDate ? new Date(launchDate) : null,
|
launchDate: launchDate ? new Date(launchDate) : null,
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
endDate: endDate ? new Date(endDate) : null,
|
||||||
curator: curator || null,
|
curator: curator || null,
|
||||||
|
hidden: Boolean(hidden),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return NextResponse.json(special);
|
return NextResponse.json(special);
|
||||||
@@ -57,21 +92,39 @@ export async function PUT(request: Request) {
|
|||||||
const authError = await requireAdminAuth(request as any);
|
const authError = await requireAdminAuth(request as any);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
|
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
return NextResponse.json({ error: 'ID required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate unlockSteps JSON if provided
|
||||||
|
if (unlockSteps !== undefined) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(unlockSteps);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return NextResponse.json({ error: 'Unlock steps must be a JSON array' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (parsed.some((item: any) => typeof item !== 'number' || item < 1)) {
|
||||||
|
return NextResponse.json({ error: 'All unlock step values must be positive numbers' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON format for unlock steps. Please use an array of numbers, e.g. [2,4,7,11,16,30,60]' }, { 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;
|
||||||
|
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
|
||||||
|
|
||||||
const updated = await prisma.special.update({
|
const updated = await prisma.special.update({
|
||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
data: {
|
data: updateData,
|
||||||
...(name && { name }),
|
|
||||||
subtitle: subtitle || null, // Allow clearing or setting
|
|
||||||
...(maxAttempts && { maxAttempts: Number(maxAttempts) }),
|
|
||||||
...(unlockSteps && { unlockSteps }),
|
|
||||||
launchDate: launchDate ? new Date(launchDate) : null,
|
|
||||||
endDate: endDate ? new Date(endDate) : null,
|
|
||||||
curator: curator || null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function GET() {
|
|||||||
for (const versionFilePath of versionPaths) {
|
for (const versionFilePath of versionPaths) {
|
||||||
if (existsSync(versionFilePath)) {
|
if (existsSync(versionFilePath)) {
|
||||||
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
const version = readFileSync(versionFilePath, 'utf-8').trim();
|
||||||
if (version && version !== 'unknown') {
|
if (version && version !== 'unknown' && version !== '') {
|
||||||
return NextResponse.json({ version });
|
return NextResponse.json({ version });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,19 @@ export async function GET() {
|
|||||||
return NextResponse.json({ version: process.env.APP_VERSION });
|
return NextResponse.json({ version: process.env.APP_VERSION });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: check package.json
|
||||||
|
try {
|
||||||
|
const packageJsonPath = join(process.cwd(), 'package.json');
|
||||||
|
if (existsSync(packageJsonPath)) {
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||||
|
if (packageJson.version) {
|
||||||
|
return NextResponse.json({ version: `v${packageJson.version}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore package.json read errors
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: try to get from git (local development)
|
// Fallback: try to get from git (local development)
|
||||||
let version = 'dev';
|
let version = 'dev';
|
||||||
|
|
||||||
|
|||||||
2181
app/curator/CuratorPageClient.tsx
Normal file
2181
app/curator/CuratorPageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
171
app/curator/help/CuratorHelpClient.tsx
Normal file
171
app/curator/help/CuratorHelpClient.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
|
||||||
|
export default function CuratorHelpClient() {
|
||||||
|
const t = useTranslations('CuratorHelp');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||||
|
<header style={{ marginBottom: '2rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>{t('title')}</h1>
|
||||||
|
<Link
|
||||||
|
href="/curator"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||||
|
{/* Einführung */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('introductionTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('introductionText')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('permissionsTitle')}</h3>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('permission4')}</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
|
||||||
|
<strong>{t('note')}:</strong> {t('permissionNote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Song-Upload */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('uploadTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('uploadStepsTitle')}</h3>
|
||||||
|
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep4')}</li>
|
||||||
|
</ol>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('uploadBestPracticesTitle')}</h3>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice3')}</li>
|
||||||
|
</ul>
|
||||||
|
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#dbeafe', borderRadius: '0.375rem', border: '1px solid #3b82f6' }}>
|
||||||
|
<strong>{t('tip')}:</strong> {t('uploadTip')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Song-Bearbeitung */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('editingTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('singleEditTitle')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('singleEditText')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('batchEditTitle')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('batchEditText')}</p>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature4')}</li>
|
||||||
|
</ul>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('genreSpecialAssignmentTitle')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('genreSpecialAssignmentText')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Specials kuratieren */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('curateSpecialsHelpTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('curateSpecialsHelpIntro')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>
|
||||||
|
{t('curateSpecialsHelpStepsTitle')}
|
||||||
|
</h3>
|
||||||
|
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}>{t('curateSpecialsHelpStep4')}</li>
|
||||||
|
</ol>
|
||||||
|
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
|
||||||
|
<strong>{t('note')}:</strong> {t('curateSpecialsPermissionsNote')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Kommentar-Verwaltung */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('commentsTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<p style={{ marginBottom: '1rem' }}>{t('commentsText')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('commentsActionsTitle')}</h3>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}><strong>{t('markAsRead')}:</strong> {t('markAsReadText')}</li>
|
||||||
|
<li style={{ marginBottom: '0.5rem' }}><strong>{t('archive')}:</strong> {t('archiveText')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Best Practices */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('bestPracticesTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice1')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice2')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice3')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice4')}</li>
|
||||||
|
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice5')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Troubleshooting */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
|
||||||
|
{t('troubleshootingTitle')}
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ1')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA1')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ2')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA2')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ3')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA3')}</p>
|
||||||
|
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ4')}</h3>
|
||||||
|
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA4')}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
8
app/curator/help/page.tsx
Normal file
8
app/curator/help/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import CuratorHelpClient from './CuratorHelpClient';
|
||||||
|
|
||||||
|
export default function CuratorHelpPage() {
|
||||||
|
return <CuratorHelpClient />;
|
||||||
|
}
|
||||||
|
|
||||||
11
app/curator/page.tsx
Normal file
11
app/curator/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Server-Wrapper für die Kuratoren-Seite.
|
||||||
|
// Markiert die Route als dynamisch und rendert die eigentliche Client-Komponente.
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import CuratorPageClient from './CuratorPageClient';
|
||||||
|
|
||||||
|
export default function CuratorPage() {
|
||||||
|
return <CuratorPageClient />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
156
app/curator/specials/CuratorSpecialsClient.tsx
Normal file
156
app/curator/specials/CuratorSpecialsClient.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface CuratorSpecial {
|
||||||
|
id: number;
|
||||||
|
name: string | { de?: string; en?: string };
|
||||||
|
songCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CuratorSpecialsClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||||
|
const intlLocale = useLocale() as 'de' | 'en';
|
||||||
|
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
|
||||||
|
const t = useTranslations('Curator');
|
||||||
|
|
||||||
|
const [specials, setSpecials] = useState<CuratorSpecial[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSpecials = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/curator/specials', {
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 403) {
|
||||||
|
setError(t('specialForbidden'));
|
||||||
|
} else {
|
||||||
|
setError('Failed to load specials');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setSpecials(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to load specials');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSpecials();
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||||
|
<p>{t('loading')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||||
|
<p style={{ color: 'red' }}>{error}</p>
|
||||||
|
<Link
|
||||||
|
href="/curator"
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard') || 'Back to Dashboard'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
|
||||||
|
<header style={{ marginBottom: '2rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>
|
||||||
|
{t('curateSpecialsTitle') || 'Curate Specials'}
|
||||||
|
</h1>
|
||||||
|
<Link
|
||||||
|
href="/curator"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard') || 'Back to Dashboard'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{specials.length === 0 ? (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: '#666' }}>
|
||||||
|
<p>{t('noSpecialsAssigned') || 'No specials assigned to you.'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
|
{specials.map((special) => (
|
||||||
|
<Link
|
||||||
|
key={special.id}
|
||||||
|
href={`/curator/specials/${special.id}`}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
padding: '1.5rem',
|
||||||
|
background: '#f9fafb',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f3f4f6';
|
||||||
|
e.currentTarget.style.borderColor = '#d1d5db';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f9fafb';
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.5rem', color: '#111827' }}>
|
||||||
|
{getLocalizedValue(special.name, locale)}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
{special.songCount} {special.songCount === 1 ? 'song' : 'songs'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '1.5rem', color: '#10b981' }}>→</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
178
app/curator/specials/[id]/page.tsx
Normal file
178
app/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||||
|
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||||
|
import HelpTooltip from '@/components/HelpTooltip';
|
||||||
|
|
||||||
|
export default function CuratorSpecialEditorPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||||
|
const intlLocale = useLocale() as 'de' | 'en';
|
||||||
|
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
|
||||||
|
const t = useTranslations('Curator');
|
||||||
|
const tHelp = useTranslations('CuratorHelp');
|
||||||
|
|
||||||
|
const specialId = params?.id as string;
|
||||||
|
|
||||||
|
const [special, setSpecial] = useState<CurateSpecial | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchSpecial = async (showLoading = true) => {
|
||||||
|
try {
|
||||||
|
if (showLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/curator/specials/${specialId}`, {
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (res.status === 403) {
|
||||||
|
setError(t('specialForbidden'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Failed to load special');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setSpecial(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to load special');
|
||||||
|
} finally {
|
||||||
|
if (showLoading) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (specialId) {
|
||||||
|
fetchSpecial(true);
|
||||||
|
}
|
||||||
|
}, [specialId, t]);
|
||||||
|
|
||||||
|
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||||
|
const res = await fetch(`/api/curator/specials/${specialId}/songs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...getCuratorAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ songId, startTime }),
|
||||||
|
});
|
||||||
|
if (res.status === 403) {
|
||||||
|
setError(t('specialForbidden'));
|
||||||
|
} else if (!res.ok) {
|
||||||
|
setError('Failed to save changes');
|
||||||
|
} else {
|
||||||
|
// Reload special data to update the start time in the song list
|
||||||
|
await fetchSpecial(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{t('loadingData')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/${locale}/curator`)}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: 'none',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!special) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{t('specialNotFound')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/${locale}/curator`)}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: 'none',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold' }}>
|
||||||
|
{t('curateSpecialHeaderPrefix')}
|
||||||
|
</h1>
|
||||||
|
<HelpTooltip
|
||||||
|
shortText={tHelp('tooltipCurateSpecialEditorShort')}
|
||||||
|
longText={tHelp('tooltipCurateSpecialEditorLong')}
|
||||||
|
position="bottom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/${locale}/curator/specials`)}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToCuratorSpecials')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CurateSpecialEditor
|
||||||
|
special={special}
|
||||||
|
locale={locale}
|
||||||
|
onBack={() => router.push(`/${locale}/curator/specials`)}
|
||||||
|
onSaveStartTime={handleSaveStartTime}
|
||||||
|
backLabel={t('backToCuratorSpecials')}
|
||||||
|
headerPrefix={t('curateSpecialHeaderPrefix')}
|
||||||
|
noSongsHint={t('curateSpecialNoSongs')}
|
||||||
|
noSongsSubHint={t('curateSpecialNoSongsSub')}
|
||||||
|
instructionsText={t('curateSpecialInstructions')}
|
||||||
|
savingLabel={t('saving')}
|
||||||
|
saveChangesLabel={t('saveChanges')}
|
||||||
|
savedLabel={t('saved')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
13
app/curator/specials/page.tsx
Normal file
13
app/curator/specials/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Root /curator/specials route without locale:
|
||||||
|
// redirect users to the default English locale version.
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function CuratorSpecialsPage() {
|
||||||
|
redirect('/en/curator/specials');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,6 +2,24 @@
|
|||||||
--foreground-rgb: 0, 0, 0;
|
--foreground-rgb: 0, 0, 0;
|
||||||
--background-start-rgb: 214, 219, 220;
|
--background-start-rgb: 214, 219, 220;
|
||||||
--background-end-rgb: 255, 255, 255;
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
|
||||||
|
/* Theme Colors */
|
||||||
|
--primary: #000000;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #4b5563;
|
||||||
|
--secondary-foreground: #ffffff;
|
||||||
|
--accent: #667eea;
|
||||||
|
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--success: #22c55e;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--warning: #ffc107;
|
||||||
|
--muted: #f3f4f6;
|
||||||
|
--muted-foreground: #6b7280;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--input: #d1d5db;
|
||||||
|
--ring: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -51,13 +69,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Audio Player */
|
/* Audio Player */
|
||||||
.audio-player {
|
.audio-player {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
@@ -73,8 +91,8 @@ body {
|
|||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #000;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -85,19 +103,20 @@ body {
|
|||||||
|
|
||||||
.play-button:hover {
|
.play-button:hover {
|
||||||
background: #333;
|
background: #333;
|
||||||
|
/* Keep for now or add --primary-hover */
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar-container {
|
.progress-bar-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 0.5rem;
|
height: 0.5rem;
|
||||||
background: #d1d5db;
|
background: var(--input);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #22c55e;
|
background: var(--success);
|
||||||
transition: width 0.1s linear;
|
transition: width 0.1s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +133,7 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
background: #f9fafb;
|
background: #f9fafb;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
@@ -125,7 +144,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guess-text {
|
.guess-text {
|
||||||
color: #ef4444;
|
color: var(--danger);
|
||||||
/* Red for wrong */
|
/* Red for wrong */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +154,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.guess-text.correct {
|
.guess-text.correct {
|
||||||
color: #22c55e;
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input */
|
/* Input */
|
||||||
@@ -148,14 +167,14 @@ body {
|
|||||||
.guess-input {
|
.guess-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guess-input:focus {
|
.guess-input:focus {
|
||||||
outline: 2px solid #000;
|
outline: 2px solid var(--ring);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +182,7 @@ body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
max-height: 15rem;
|
max-height: 15rem;
|
||||||
@@ -177,11 +196,11 @@ body {
|
|||||||
.suggestion-item {
|
.suggestion-item {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-item:hover {
|
.suggestion-item:hover {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-title {
|
.suggestion-title {
|
||||||
@@ -190,14 +209,14 @@ body {
|
|||||||
|
|
||||||
.suggestion-artist {
|
.suggestion-artist {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip-button {
|
.skip-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: var(--accent-gradient);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
@@ -246,7 +265,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-card {
|
.admin-card {
|
||||||
background: #f3f4f6;
|
background: var(--muted);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -265,14 +284,14 @@ body {
|
|||||||
.form-input {
|
.form-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
border: 1px solid #d1d5db;
|
border: 1px solid var(--input);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #000;
|
background: var(--primary);
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -292,8 +311,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #4b5563;
|
background: var(--secondary);
|
||||||
color: #fff;
|
color: var(--secondary-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -312,8 +331,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: #ef4444;
|
background: var(--danger);
|
||||||
color: #fff;
|
color: var(--danger-foreground);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
@@ -337,8 +356,8 @@ body {
|
|||||||
padding: 2rem 1rem 1rem;
|
padding: 2rem 1rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid var(--border);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +366,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-footer a {
|
.app-footer a {
|
||||||
color: #000;
|
color: var(--primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -375,7 +394,7 @@ body {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.statistics-grid {
|
.statistics-grid {
|
||||||
@@ -391,7 +410,7 @@ body {
|
|||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.8);
|
background: rgba(255, 255, 255, 0.8);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-badge {
|
.stat-badge {
|
||||||
@@ -401,7 +420,7 @@ body {
|
|||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
color: var(--muted-foreground);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -409,7 +428,7 @@ body {
|
|||||||
.stat-count {
|
.stat-count {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #000;
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip */
|
/* Tooltip */
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import Script from "next/script";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Hördle",
|
|
||||||
description: "Daily music guessing game - Guess the song from short audio clips",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
themeColor: "#000000",
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1,
|
|
||||||
maximumScale: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
import InstallPrompt from "@/components/InstallPrompt";
|
|
||||||
import AppFooter from "@/components/AppFooter";
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<Script
|
|
||||||
defer
|
|
||||||
data-domain="hoerdle.elpatron.me"
|
|
||||||
src="https://plausible.elpatron.me/js/script.js"
|
|
||||||
strategy="beforeInteractive"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
|
||||||
{children}
|
|
||||||
<InstallPrompt />
|
|
||||||
<AppFooter />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { MetadataRoute } from 'next'
|
import type { MetadataRoute } from 'next'
|
||||||
|
import { config } from '@/lib/config'
|
||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: 'Hördle',
|
name: config.appName,
|
||||||
short_name: 'Hördle',
|
short_name: config.appName,
|
||||||
description: 'Daily music guessing game - Guess the song from short audio clips',
|
description: config.appDescription,
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#ffffff',
|
background_color: config.colors.backgroundColor,
|
||||||
theme_color: '#000000',
|
theme_color: config.colors.themeColor,
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: '/favicon.ico',
|
src: '/favicon.ico',
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
.page {
|
|
||||||
--background: #fafafa;
|
|
||||||
--foreground: #fff;
|
|
||||||
|
|
||||||
--text-primary: #000;
|
|
||||||
--text-secondary: #666;
|
|
||||||
|
|
||||||
--button-primary-hover: #383838;
|
|
||||||
--button-secondary-hover: #f2f2f2;
|
|
||||||
--button-secondary-border: #ebebeb;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
background-color: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
background-color: var(--foreground);
|
|
||||||
padding: 120px 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
text-align: left;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro h1 {
|
|
||||||
max-width: 320px;
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 48px;
|
|
||||||
letter-spacing: -2.4px;
|
|
||||||
text-wrap: balance;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro p {
|
|
||||||
max-width: 440px;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 32px;
|
|
||||||
text-wrap: balance;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro a {
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 440px;
|
|
||||||
gap: 16px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas a {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-radius: 128px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
width: fit-content;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.primary {
|
|
||||||
background: var(--text-primary);
|
|
||||||
color: var(--background);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary {
|
|
||||||
border-color: var(--button-secondary-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Enable hover only on non-touch devices */
|
|
||||||
@media (hover: hover) and (pointer: fine) {
|
|
||||||
a.primary:hover {
|
|
||||||
background: var(--button-primary-hover);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary:hover {
|
|
||||||
background: var(--button-secondary-hover);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.main {
|
|
||||||
padding: 48px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro {
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.intro h1 {
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 40px;
|
|
||||||
letter-spacing: -1.92px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.logo {
|
|
||||||
filter: invert();
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
|
||||||
--background: #000;
|
|
||||||
--foreground: #000;
|
|
||||||
|
|
||||||
--text-primary: #ededed;
|
|
||||||
--text-secondary: #999;
|
|
||||||
|
|
||||||
--button-primary-hover: #ccc;
|
|
||||||
--button-secondary-hover: #1a1a1a;
|
|
||||||
--button-secondary-border: #1a1a1a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
103
app/page.tsx
103
app/page.tsx
@@ -1,103 +0,0 @@
|
|||||||
import Game from '@/components/Game';
|
|
||||||
import NewsSection from '@/components/NewsSection';
|
|
||||||
import { getOrCreateDailyPuzzle } from '@/lib/dailyPuzzle';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
export default async function Home() {
|
|
||||||
const dailyPuzzle = await getOrCreateDailyPuzzle(null); // Global puzzle
|
|
||||||
const genres = await prisma.genre.findMany({
|
|
||||||
where: { active: true },
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const activeSpecials = specials.filter(s => {
|
|
||||||
const isStarted = !s.launchDate || s.launchDate <= now;
|
|
||||||
const isEnded = s.endDate && s.endDate < now;
|
|
||||||
return isStarted && !isEnded;
|
|
||||||
});
|
|
||||||
|
|
||||||
const upcomingSpecials = specials.filter(s => {
|
|
||||||
return s.launchDate && s.launchDate > now;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#f3f4f6' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<div className="tooltip">
|
|
||||||
<Link href="/" style={{ fontWeight: 'bold', textDecoration: 'underline' }}>Global</Link>
|
|
||||||
<span className="tooltip-text">A random song from the entire collection</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{genres.map(g => (
|
|
||||||
<div key={g.id} className="tooltip">
|
|
||||||
<Link href={`/${g.name}`} style={{ color: '#4b5563', textDecoration: 'none' }}>
|
|
||||||
{g.name}
|
|
||||||
</Link>
|
|
||||||
{g.subtitle && <span className="tooltip-text">{g.subtitle}</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
|
||||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active Specials */}
|
|
||||||
{activeSpecials.map(s => (
|
|
||||||
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
<div className="tooltip">
|
|
||||||
<Link
|
|
||||||
href={`/special/${s.name}`}
|
|
||||||
style={{
|
|
||||||
color: '#be185d', // Pink-700
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ {s.name}
|
|
||||||
</Link>
|
|
||||||
{s.subtitle && <span className="tooltip-text">{s.subtitle}</span>}
|
|
||||||
</div>
|
|
||||||
{s.curator && (
|
|
||||||
<span style={{ fontSize: '0.75rem', color: '#666' }}>
|
|
||||||
Curated by {s.curator}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upcoming Specials */}
|
|
||||||
{upcomingSpecials.length > 0 && (
|
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
|
||||||
Coming soon: {upcomingSpecials.map(s => (
|
|
||||||
<span key={s.id} style={{ marginLeft: '0.5rem' }}>
|
|
||||||
★ {s.name} ({s.launchDate ? new Date(s.launchDate).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: process.env.TZ
|
|
||||||
}) : ''})
|
|
||||||
{s.curator && <span style={{ fontStyle: 'italic', marginLeft: '0.25rem' }}>Curated by {s.curator}</span>}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewsSection />
|
|
||||||
|
|
||||||
<Game dailyPuzzle={dailyPuzzle} genre={null} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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,111 +0,0 @@
|
|||||||
import Game from '@/components/Game';
|
|
||||||
import NewsSection from '@/components/NewsSection';
|
|
||||||
import { getOrCreateSpecialPuzzle } from '@/lib/dailyPuzzle';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { PrismaClient } from '@prisma/client';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ name: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SpecialPage({ params }: PageProps) {
|
|
||||||
const { name } = await params;
|
|
||||||
const decodedName = decodeURIComponent(name);
|
|
||||||
|
|
||||||
const currentSpecial = await prisma.special.findUnique({
|
|
||||||
where: { name: decodedName }
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const isStarted = currentSpecial && (!currentSpecial.launchDate || currentSpecial.launchDate <= now);
|
|
||||||
const isEnded = currentSpecial && (currentSpecial.endDate && currentSpecial.endDate < now);
|
|
||||||
|
|
||||||
if (!currentSpecial || !isStarted) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
||||||
<h1>Special Not Available</h1>
|
|
||||||
<p>This special has not launched yet or does not exist.</p>
|
|
||||||
<Link href="/">Go Home</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEnded) {
|
|
||||||
return (
|
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
||||||
<h1>Special Ended</h1>
|
|
||||||
<p>This special event has ended.</p>
|
|
||||||
<Link href="/">Go Home</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyPuzzle = await getOrCreateSpecialPuzzle(decodedName);
|
|
||||||
const genres = await prisma.genre.findMany({
|
|
||||||
where: { active: true },
|
|
||||||
orderBy: { name: 'asc' }
|
|
||||||
});
|
|
||||||
const specials = await prisma.special.findMany({ orderBy: { name: 'asc' } });
|
|
||||||
|
|
||||||
const activeSpecials = specials.filter(s => {
|
|
||||||
const sStarted = !s.launchDate || s.launchDate <= now;
|
|
||||||
const sEnded = s.endDate && s.endDate < now;
|
|
||||||
return sStarted && !sEnded;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', background: '#fce7f3' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
||||||
<Link href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Global</Link>
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{genres.map(g => (
|
|
||||||
<Link
|
|
||||||
key={g.id}
|
|
||||||
href={`/${g.name}`}
|
|
||||||
style={{
|
|
||||||
color: '#4b5563',
|
|
||||||
textDecoration: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{g.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Separator if both exist */}
|
|
||||||
{genres.length > 0 && activeSpecials.length > 0 && (
|
|
||||||
<span style={{ color: '#d1d5db' }}>|</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Specials */}
|
|
||||||
{activeSpecials.map(s => (
|
|
||||||
<Link
|
|
||||||
key={s.id}
|
|
||||||
href={`/special/${s.name}`}
|
|
||||||
style={{
|
|
||||||
fontWeight: s.name === decodedName ? 'bold' : 'normal',
|
|
||||||
textDecoration: s.name === decodedName ? 'underline' : 'none',
|
|
||||||
color: s.name === decodedName ? '#9d174d' : '#be185d'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
★ {s.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<NewsSection />
|
|
||||||
<Game
|
|
||||||
dailyPuzzle={dailyPuzzle}
|
|
||||||
genre={decodedName}
|
|
||||||
isSpecial={true}
|
|
||||||
maxAttempts={dailyPuzzle?.maxAttempts}
|
|
||||||
unlockSteps={dailyPuzzle?.unlockSteps}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,47 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { config } from "@/lib/config";
|
||||||
|
import { Link } from "@/lib/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function AppFooter() {
|
export default function AppFooter() {
|
||||||
const [version, setVersion] = useState<string>('');
|
const [version, setVersion] = useState<string>("");
|
||||||
|
const t = useTranslations("About");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/version')
|
fetch("/api/version")
|
||||||
.then(res => res.json())
|
.then((res) => res.json())
|
||||||
.then(data => setVersion(data.version))
|
.then((data) => setVersion(data.version))
|
||||||
.catch(() => setVersion(''));
|
.catch(() => setVersion(""));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
if (!config.credits.enabled) return null;
|
||||||
<footer className="app-footer">
|
|
||||||
<p>
|
return (
|
||||||
Vibe coded with ☕ and 🍺 by{' '}
|
<footer className="app-footer">
|
||||||
<a href="https://digitalcourage.social/@elpatron" target="_blank" rel="noopener noreferrer">
|
<p>
|
||||||
@elpatron@digitalcourage.social
|
{config.credits.text}{" "}
|
||||||
</a>
|
<a
|
||||||
{' '}- for personal use among friends only!
|
href={config.credits.linkUrl}
|
||||||
{version && (
|
target="_blank"
|
||||||
<>
|
rel="noopener noreferrer"
|
||||||
{' '}·{' '}
|
>
|
||||||
<span style={{ fontSize: '0.85em', opacity: 0.7 }}>
|
{config.credits.linkText}
|
||||||
{version}
|
</a>
|
||||||
</span>
|
{version && (
|
||||||
</>
|
<>
|
||||||
)}
|
{" "}
|
||||||
</p>
|
·{" "}
|
||||||
</footer>
|
<span style={{ fontSize: "0.85em", opacity: 0.7 }}>{version}</span>
|
||||||
);
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>
|
||||||
|
<Link href="/about" style={{ textDecoration: "underline" }}>
|
||||||
|
{t("footerLinkLabel")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,39 +22,106 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
||||||
|
|
||||||
|
const [processedSrc, setProcessedSrc] = useState<string | null>(null);
|
||||||
|
const [processedUnlockedSeconds, setProcessedUnlockedSeconds] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[AudioPlayer] MOUNTED');
|
||||||
|
return () => console.log('[AudioPlayer] UNMOUNTED');
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
// Check if props changed compared to what we last processed
|
||||||
audioRef.current.currentTime = startTime;
|
const hasChanged = src !== processedSrc || unlockedSeconds !== processedUnlockedSeconds;
|
||||||
setIsPlaying(false);
|
|
||||||
setProgress(0);
|
|
||||||
setHasPlayedOnce(false); // Reset for new segment
|
|
||||||
onHasPlayedChange?.(false); // Notify parent
|
|
||||||
|
|
||||||
if (autoPlay) {
|
if (hasChanged) {
|
||||||
const playPromise = audioRef.current.play();
|
audioRef.current.pause();
|
||||||
if (playPromise !== undefined) {
|
|
||||||
playPromise
|
let startPos = startTime;
|
||||||
.then(() => {
|
|
||||||
setIsPlaying(true);
|
// If same song but more time unlocked, start from where previous segment ended
|
||||||
onPlay?.();
|
if (processedSrc !== null && src === processedSrc && processedUnlockedSeconds !== null && unlockedSeconds > processedUnlockedSeconds) {
|
||||||
setHasPlayedOnce(true);
|
startPos = startTime + processedUnlockedSeconds;
|
||||||
onHasPlayedChange?.(true); // Notify parent
|
}
|
||||||
})
|
|
||||||
.catch(error => {
|
const targetPos = startPos;
|
||||||
console.log("Autoplay prevented:", error);
|
audioRef.current.currentTime = targetPos;
|
||||||
setIsPlaying(false);
|
|
||||||
});
|
// Ensure position is set correctly even if browser resets it
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
|
||||||
|
audioRef.current.currentTime = targetPos;
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
setIsPlaying(false);
|
||||||
|
|
||||||
|
// Calculate initial progress
|
||||||
|
const initialElapsed = startPos - startTime;
|
||||||
|
const initialPercent = unlockedSeconds > 0 ? (initialElapsed / unlockedSeconds) * 100 : 0;
|
||||||
|
setProgress(Math.min(initialPercent, 100));
|
||||||
|
|
||||||
|
// Only reset hasPlayedOnce if the song changed, not if just more time was unlocked
|
||||||
|
if (processedSrc !== null && src !== processedSrc) {
|
||||||
|
setHasPlayedOnce(false); // Reset for new song
|
||||||
|
onHasPlayedChange?.(false); // Notify parent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update processed state
|
||||||
|
setProcessedSrc(src);
|
||||||
|
setProcessedUnlockedSeconds(unlockedSeconds);
|
||||||
|
|
||||||
|
if (autoPlay) {
|
||||||
|
// Delay play slightly to ensure currentTime sticks
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
// Use startPos (which may be startTime + processedUnlockedSeconds if more time was unlocked)
|
||||||
|
// instead of always using startTime
|
||||||
|
audioRef.current.currentTime = startPos;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else if (startTime !== undefined && startTime > 0) {
|
||||||
|
// If startTime is set and we haven't processed changes, ensure currentTime is at least at startTime
|
||||||
|
// This handles the case where the audio element was reset or reloaded, or when manually playing for the first time
|
||||||
|
const current = audioRef.current.currentTime;
|
||||||
|
if (current < startTime) {
|
||||||
|
audioRef.current.currentTime = startTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [src, unlockedSeconds, startTime, autoPlay]);
|
}, [src, unlockedSeconds, startTime, autoPlay, processedSrc, processedUnlockedSeconds]);
|
||||||
|
|
||||||
// Expose play method to parent component
|
// Expose play method to parent component
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
play: () => {
|
play: () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
|
|
||||||
|
// Check if we need to reset to startTime
|
||||||
|
const current = audioRef.current.currentTime;
|
||||||
|
const elapsed = current - startTime;
|
||||||
|
|
||||||
|
// Reset if: never played before, current position is before startTime, or we've exceeded the unlocked segment
|
||||||
|
if (!hasPlayedOnce || current < startTime || elapsed >= unlockedSeconds) {
|
||||||
|
// Reset to start of segment
|
||||||
|
audioRef.current.currentTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
const playPromise = audioRef.current.play();
|
const playPromise = audioRef.current.play();
|
||||||
if (playPromise !== undefined) {
|
if (playPromise !== undefined) {
|
||||||
playPromise
|
playPromise
|
||||||
@@ -79,8 +146,35 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
} else {
|
} else {
|
||||||
|
// Ensure we're at the correct position before playing
|
||||||
|
const current = audioRef.current.currentTime;
|
||||||
|
const elapsed = current - startTime;
|
||||||
|
|
||||||
|
// Determine target position
|
||||||
|
let targetPos = startTime;
|
||||||
|
|
||||||
|
// If we've played before and we're within the unlocked segment, continue from current position
|
||||||
|
if (hasPlayedOnce && current >= startTime && elapsed < unlockedSeconds) {
|
||||||
|
targetPos = current; // Continue from current position
|
||||||
|
} else {
|
||||||
|
// Reset to start of segment if: never played, before startTime, or exceeded unlocked segment
|
||||||
|
targetPos = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set position before playing
|
||||||
|
audioRef.current.currentTime = targetPos;
|
||||||
|
|
||||||
|
// Ensure position sticks (browser might reset it)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (audioRef.current && Math.abs(audioRef.current.currentTime - targetPos) > 0.5) {
|
||||||
|
audioRef.current.currentTime = targetPos;
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
audioRef.current.play();
|
audioRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
onPlay?.();
|
onPlay?.();
|
||||||
|
|
||||||
if (hasPlayedOnce) {
|
if (hasPlayedOnce) {
|
||||||
@@ -90,7 +184,6 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
onHasPlayedChange?.(true); // Notify parent
|
onHasPlayedChange?.(true); // Notify parent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setIsPlaying(!isPlaying);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
const handleTimeUpdate = () => {
|
||||||
@@ -148,4 +241,6 @@ const AudioPlayer = forwardRef<AudioPlayerRef, AudioPlayerProps>(({ src, unlocke
|
|||||||
|
|
||||||
AudioPlayer.displayName = 'AudioPlayer';
|
AudioPlayer.displayName = 'AudioPlayer';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default AudioPlayer;
|
export default AudioPlayer;
|
||||||
|
|||||||
208
components/CurateSpecialEditor.tsx
Normal file
208
components/CurateSpecialEditor.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import WaveformEditor from '@/components/WaveformEditor';
|
||||||
|
|
||||||
|
export type LocalizedString = string | { de: string; en: string };
|
||||||
|
|
||||||
|
export interface CurateSpecialSong {
|
||||||
|
id: number;
|
||||||
|
songId: number;
|
||||||
|
startTime: number;
|
||||||
|
order: number | null;
|
||||||
|
song: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurateSpecial {
|
||||||
|
id: number;
|
||||||
|
name: LocalizedString;
|
||||||
|
subtitle?: LocalizedString | null;
|
||||||
|
maxAttempts: number;
|
||||||
|
unlockSteps: string;
|
||||||
|
songs: CurateSpecialSong[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurateSpecialEditorProps {
|
||||||
|
special: CurateSpecial;
|
||||||
|
locale: 'de' | 'en';
|
||||||
|
onBack: () => void;
|
||||||
|
onSaveStartTime: (songId: number, startTime: number) => Promise<void>;
|
||||||
|
backLabel?: string;
|
||||||
|
headerPrefix?: string;
|
||||||
|
noSongsHint?: string;
|
||||||
|
noSongsSubHint?: string;
|
||||||
|
instructionsText?: string;
|
||||||
|
savingLabel?: string;
|
||||||
|
saveChangesLabel?: string;
|
||||||
|
savedLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveLocalized = (value: LocalizedString | null | undefined, locale: 'de' | 'en'): string | undefined => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
return value[locale] ?? value.en ?? value.de;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CurateSpecialEditor({
|
||||||
|
special,
|
||||||
|
locale,
|
||||||
|
onBack,
|
||||||
|
onSaveStartTime,
|
||||||
|
backLabel = '← Back',
|
||||||
|
headerPrefix = 'Edit Special:',
|
||||||
|
noSongsHint = 'No songs assigned to this special yet.',
|
||||||
|
noSongsSubHint = 'Go back to the dashboard to add songs to this special.',
|
||||||
|
instructionsText = 'Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.',
|
||||||
|
savingLabel = '💾 Saving...',
|
||||||
|
saveChangesLabel = '💾 Save Changes',
|
||||||
|
savedLabel = '✓ Saved',
|
||||||
|
}: CurateSpecialEditorProps) {
|
||||||
|
// Filtere Songs ohne vollständige Song-Daten (song, song.filename)
|
||||||
|
const validSongs = special.songs.filter(ss => ss.song && ss.song.filename);
|
||||||
|
|
||||||
|
const [selectedSongId, setSelectedSongId] = useState<number | null>(
|
||||||
|
validSongs.length > 0 ? validSongs[0].songId : null
|
||||||
|
);
|
||||||
|
const [pendingStartTime, setPendingStartTime] = useState<number | null>(
|
||||||
|
validSongs.length > 0 ? validSongs[0].startTime : null
|
||||||
|
);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const specialName = resolveLocalized(special.name, locale) ?? `Special #${special.id}`;
|
||||||
|
const specialSubtitle = resolveLocalized(special.subtitle ?? null, locale);
|
||||||
|
|
||||||
|
const unlockSteps = JSON.parse(special.unlockSteps);
|
||||||
|
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
||||||
|
|
||||||
|
const selectedSpecialSong = validSongs.find(ss => ss.songId === selectedSongId) ?? null;
|
||||||
|
|
||||||
|
const handleStartTimeChange = (newStartTime: number) => {
|
||||||
|
setPendingStartTime(newStartTime);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedSongId || pendingStartTime === null) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSaveStartTime(selectedSongId, pendingStartTime);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{headerPrefix} {specialName}
|
||||||
|
</h1>
|
||||||
|
{specialSubtitle && (
|
||||||
|
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
|
||||||
|
{specialSubtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
||||||
|
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validSongs.length === 0 ? (
|
||||||
|
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
||||||
|
<p>{noSongsHint}</p>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||||
|
{noSongsSubHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||||
|
Select Song to Curate
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
||||||
|
{validSongs.map(ss => (
|
||||||
|
<div
|
||||||
|
key={ss.songId}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSongId(ss.songId);
|
||||||
|
setPendingStartTime(ss.startTime);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
||||||
|
color: selectedSongId === ss.songId ? 'white' : 'black',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
||||||
|
Start: {ss.startTime}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSpecialSong && selectedSpecialSong.song && selectedSpecialSong.song.filename ? (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||||
|
Curate: {selectedSpecialSong.song.title}
|
||||||
|
</h2>
|
||||||
|
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
||||||
|
{instructionsText}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasUnsavedChanges || saving}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.5rem',
|
||||||
|
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
||||||
|
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? savingLabel : hasUnsavedChanges ? saveChangesLabel : savedLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<WaveformEditor
|
||||||
|
audioUrl={`/api/audio/${selectedSpecialSong.song.filename}`}
|
||||||
|
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
||||||
|
duration={totalDuration}
|
||||||
|
unlockSteps={unlockSteps}
|
||||||
|
onStartTimeChange={handleStartTimeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedSpecialSong ? (
|
||||||
|
<div style={{ padding: '2rem', background: '#fee2e2', borderRadius: '0.5rem', textAlign: 'center' }}>
|
||||||
|
<p style={{ color: '#991b1b', fontWeight: 'bold' }}>
|
||||||
|
Fehler: Song-Daten unvollständig. Bitte wählen Sie einen anderen Song.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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,11 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { config } from '@/lib/config';
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
import AudioPlayer, { AudioPlayerRef } from './AudioPlayer';
|
||||||
import GuessInput from './GuessInput';
|
import GuessInput from './GuessInput';
|
||||||
import Statistics from './Statistics';
|
import Statistics from './Statistics';
|
||||||
|
import ExtraPuzzlesPopover from './ExtraPuzzlesPopover';
|
||||||
import { useGameState } from '../lib/gameState';
|
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';
|
import { sendGotifyNotification, submitRating } from '../app/actions';
|
||||||
|
import { getOrCreatePlayerId } from '@/lib/playerId';
|
||||||
|
|
||||||
|
// Plausible Analytics
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
plausible?: (eventName: string, options?: { props?: Record<string, string | number> }) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface GameProps {
|
interface GameProps {
|
||||||
dailyPuzzle: {
|
dailyPuzzle: {
|
||||||
@@ -23,22 +38,36 @@ interface GameProps {
|
|||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
unlockSteps?: 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];
|
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||||
|
|
||||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS, requiredDailyKeys }: GameProps) {
|
||||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts);
|
const t = useTranslations('Game');
|
||||||
const [hasWon, setHasWon] = useState(false);
|
const locale = useLocale();
|
||||||
const [hasLost, setHasLost] = useState(false);
|
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts, isSpecial);
|
||||||
const [shareText, setShareText] = useState('🔗 Share');
|
const [hasWon, setHasWon] = useState(gameState?.isSolved ?? false);
|
||||||
|
const [hasLost, setHasLost] = useState(gameState?.isFailed ?? false);
|
||||||
|
const [shareText, setShareText] = useState(`🔗 ${t('share')}`);
|
||||||
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null);
|
||||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||||
const [hasRated, setHasRated] = useState(false);
|
const [hasRated, setHasRated] = useState(false);
|
||||||
const [showYearModal, setShowYearModal] = useState(false);
|
const [showYearModal, setShowYearModal] = useState(false);
|
||||||
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
const [hasPlayedAudio, setHasPlayedAudio] = useState(false);
|
||||||
|
const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false);
|
||||||
|
const [extraPuzzle, setExtraPuzzle] = useState<ExternalPuzzle | null>(null);
|
||||||
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
const audioPlayerRef = useRef<AudioPlayerRef>(null);
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
const [commentSending, setCommentSending] = useState(false);
|
||||||
|
const [commentSent, setCommentSent] = useState(false);
|
||||||
|
const [commentError, setCommentError] = useState<string | null>(null);
|
||||||
|
const [commentCollapsed, setCommentCollapsed] = useState(true);
|
||||||
|
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
|
||||||
|
const [commentAIConsent, setCommentAIConsent] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
@@ -59,50 +88,105 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gameState && dailyPuzzle) {
|
if (gameState) {
|
||||||
setHasWon(gameState.isSolved);
|
setHasWon(gameState.isSolved);
|
||||||
setHasLost(gameState.isFailed);
|
setHasLost(gameState.isFailed);
|
||||||
|
|
||||||
// Show year modal if won but year not guessed yet and release year is available
|
// Show year modal if won but year not guessed yet and release year is available
|
||||||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
|
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle?.releaseYear) {
|
||||||
setShowYearModal(true);
|
setShowYearModal(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [gameState, dailyPuzzle]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setLastAction(null);
|
setLastAction(null);
|
||||||
}, [dailyPuzzle?.id]);
|
}, [dailyPuzzle?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dailyPuzzle) {
|
if (dailyPuzzle) {
|
||||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||||
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
if (ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
setHasRated(true);
|
setHasRated(true);
|
||||||
} else {
|
} else {
|
||||||
setHasRated(false);
|
setHasRated(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if comment already sent for this puzzle
|
||||||
|
const playerIdentifier = getOrCreatePlayerId();
|
||||||
|
if (playerIdentifier) {
|
||||||
|
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||||
|
if (commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
|
setCommentSent(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [dailyPuzzle]);
|
}, [dailyPuzzle]);
|
||||||
|
|
||||||
if (!dailyPuzzle) return (
|
if (!dailyPuzzle) return (
|
||||||
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
<div className="game-container" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
<h2>No Puzzle Available</h2>
|
<h2>{t('noPuzzleAvailable')}</h2>
|
||||||
<p>Could not generate a daily puzzle.</p>
|
<p>{t('noPuzzleDescription')}</p>
|
||||||
<p>Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.</p>
|
<p>{t('noPuzzleGenre')}{genre ? ` für Genre "${genre}"` : ''}.</p>
|
||||||
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>Go to Admin Dashboard</a>
|
<a href="/admin" style={{ color: 'var(--primary)', textDecoration: 'underline' }}>{t('goToAdmin')}</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!gameState) return <div>Loading state...</div>;
|
if (!gameState) return <div>{t('loadingState')}</div>;
|
||||||
|
|
||||||
const handleGuess = (song: any) => {
|
const handleGuess = (song: any) => {
|
||||||
if (isProcessingGuess) return;
|
if (isProcessingGuess) return;
|
||||||
|
// Prevent guessing if already solved or failed
|
||||||
|
if (gameState?.isSolved || gameState?.isFailed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsProcessingGuess(true);
|
setIsProcessingGuess(true);
|
||||||
setLastAction('GUESS');
|
setLastAction('GUESS');
|
||||||
if (song.id === dailyPuzzle.songId) {
|
if (song.id === dailyPuzzle.songId) {
|
||||||
addGuess(song.title, true);
|
addGuess(song.title, true);
|
||||||
setHasWon(true);
|
setHasWon(true);
|
||||||
|
// Track puzzle solved event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length + 1,
|
||||||
|
score: gameState.score + 20, // Include the win bonus
|
||||||
|
outcome: 'won'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// Notification sent after year guess or skip
|
// Notification sent after year guess or skip
|
||||||
if (!dailyPuzzle.releaseYear) {
|
if (!dailyPuzzle.releaseYear) {
|
||||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
@@ -112,6 +196,17 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: maxAttempts,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +221,9 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
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 user hasn't played audio yet on first attempt, start it instead of skipping
|
||||||
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
if (gameState.guesses.length === 0 && !hasPlayedAudio) {
|
||||||
handleStartAudio();
|
handleStartAudio();
|
||||||
@@ -138,16 +236,41 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: maxAttempts,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGiveUp = () => {
|
const handleGiveUp = () => {
|
||||||
|
// Prevent giving up if already solved or failed
|
||||||
|
if (gameState?.isSolved || gameState?.isFailed) return;
|
||||||
|
|
||||||
setLastAction('SKIP');
|
setLastAction('SKIP');
|
||||||
addGuess("SKIPPED", false);
|
addGuess("SKIPPED", false);
|
||||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||||
setHasLost(true);
|
setHasLost(true);
|
||||||
setHasWon(false);
|
setHasWon(false);
|
||||||
|
// Track puzzle lost event
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length + 1,
|
||||||
|
score: 0,
|
||||||
|
outcome: 'lost'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +279,19 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
addYearBonus(correct);
|
addYearBonus(correct);
|
||||||
setShowYearModal(false);
|
setShowYearModal(false);
|
||||||
|
|
||||||
|
// Update the puzzle_solved event with year bonus result
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length,
|
||||||
|
score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct
|
||||||
|
outcome: 'won',
|
||||||
|
year_bonus: correct ? 'correct' : 'incorrect'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification now that game is fully complete
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||||
};
|
};
|
||||||
@@ -163,10 +299,106 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const handleYearSkip = () => {
|
const handleYearSkip = () => {
|
||||||
skipYearBonus();
|
skipYearBonus();
|
||||||
setShowYearModal(false);
|
setShowYearModal(false);
|
||||||
|
|
||||||
|
// Update the puzzle_solved event with year bonus result
|
||||||
|
if (typeof window !== 'undefined' && window.plausible) {
|
||||||
|
window.plausible('puzzle_solved', {
|
||||||
|
props: {
|
||||||
|
genre: genre || 'Global',
|
||||||
|
attempts: gameState.guesses.length,
|
||||||
|
score: gameState.score, // Score already includes win bonus
|
||||||
|
outcome: 'won',
|
||||||
|
year_bonus: 'skipped'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification now that game is fully complete
|
// Send notification now that game is fully complete
|
||||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCommentSubmit = async () => {
|
||||||
|
if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle || !commentAIConsent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommentSending(true);
|
||||||
|
setCommentError(null);
|
||||||
|
setRewrittenMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playerIdentifier = getOrCreatePlayerId();
|
||||||
|
if (!playerIdentifier) {
|
||||||
|
throw new Error('Could not get player identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Rewrite message using AI
|
||||||
|
const rewriteResponse = await fetch('/api/rewrite-message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: commentText.trim() })
|
||||||
|
});
|
||||||
|
|
||||||
|
let finalMessage = commentText.trim();
|
||||||
|
if (rewriteResponse.ok) {
|
||||||
|
const rewriteData = await rewriteResponse.json();
|
||||||
|
if (rewriteData.rewrittenMessage) {
|
||||||
|
finalMessage = rewriteData.rewrittenMessage;
|
||||||
|
// Only show rewritten message if it was actually changed
|
||||||
|
// The API adds "(autocorrected by Polite-Bot)" suffix only if message was changed
|
||||||
|
const wasChanged = finalMessage.includes('(autocorrected by Polite-Bot)');
|
||||||
|
if (wasChanged) {
|
||||||
|
// Remove the suffix for display
|
||||||
|
const displayMessage = finalMessage.replace(/\s*\(autocorrected by Polite-Bot\)\s*/g, '').trim();
|
||||||
|
setRewrittenMessage(displayMessage);
|
||||||
|
} else {
|
||||||
|
// Ensure rewrittenMessage is not set if message wasn't changed
|
||||||
|
setRewrittenMessage(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Send comment
|
||||||
|
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
|
||||||
|
const genreId = isSpecial ? null : null; // API will determine from puzzle
|
||||||
|
|
||||||
|
const response = await fetch('/api/curator-comment', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
puzzleId: dailyPuzzle.id,
|
||||||
|
genreId: genreId,
|
||||||
|
message: finalMessage,
|
||||||
|
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
|
||||||
|
playerIdentifier: playerIdentifier
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to send comment');
|
||||||
|
}
|
||||||
|
|
||||||
|
setCommentSent(true);
|
||||||
|
setCommentText('');
|
||||||
|
|
||||||
|
// Store in localStorage that comment was sent
|
||||||
|
const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]');
|
||||||
|
if (!commentedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
|
commentedPuzzles.push(dailyPuzzle.id);
|
||||||
|
localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending comment:', error);
|
||||||
|
setCommentError(error instanceof Error ? error.message : 'Failed to send comment');
|
||||||
|
} finally {
|
||||||
|
setCommentSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
@@ -175,23 +407,44 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
for (let i = 0; i < totalGuesses; i++) {
|
for (let i = 0; i < totalGuesses; i++) {
|
||||||
if (i < gameState.guesses.length) {
|
if (i < gameState.guesses.length) {
|
||||||
if (hasWon && i === gameState.guesses.length - 1) {
|
if (gameState.guesses[i] === 'SKIPPED') {
|
||||||
emojiGrid += '🟩';
|
|
||||||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
|
||||||
emojiGrid += '⬛';
|
emojiGrid += '⬛';
|
||||||
|
} else if (hasWon && i === gameState.guesses.length - 1) {
|
||||||
|
emojiGrid += '🟩';
|
||||||
} else {
|
} else {
|
||||||
emojiGrid += '🟥';
|
emojiGrid += '🟥';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
emojiGrid += '⬜';
|
// If game is lost, fill remaining slots with black squares
|
||||||
|
emojiGrid += hasLost ? '⬛' : '⬜';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const speaker = hasWon ? '🔉' : '🔇';
|
const speaker = hasWon ? '🔉' : '🔇';
|
||||||
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : '';
|
||||||
const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : '';
|
const genreText = genre ? `${isSpecial ? t('special') : t('genre')}: ${genre}\n` : '';
|
||||||
|
|
||||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
// Use current domain from window.location to support both hoerdle.de and hördle.de
|
||||||
|
const rawHost = typeof window !== 'undefined' ? window.location.hostname : config.domain;
|
||||||
|
const protocol = typeof window !== 'undefined' ? window.location.protocol : 'https:';
|
||||||
|
|
||||||
|
// For users on hördle.de, use Punycode domain (xn--hrdle-jua.de) in share message
|
||||||
|
// to avoid rendering issues with Unicode domains
|
||||||
|
let currentHost = rawHost;
|
||||||
|
if (rawHost === 'hördle.de' || rawHost === 'xn--hrdle-jua.de') {
|
||||||
|
currentHost = 'xn--hrdle-jua.de';
|
||||||
|
}
|
||||||
|
|
||||||
|
// OLD CODE (commented out - may be needed again in the future):
|
||||||
|
// 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 currentHost = rawHost === 'xn--hrdle-jua.de' ? 'hördle.de' : rawHost;
|
||||||
|
|
||||||
|
let shareUrl = `${protocol}//${currentHost}`;
|
||||||
|
// Add locale prefix if not default (en)
|
||||||
|
if (locale !== 'en') {
|
||||||
|
shareUrl += `/${locale}`;
|
||||||
|
}
|
||||||
if (genre) {
|
if (genre) {
|
||||||
if (isSpecial) {
|
if (isSpecial) {
|
||||||
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
shareUrl += `/special/${encodeURIComponent(genre)}`;
|
||||||
@@ -200,7 +453,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
const text = `${config.appName} #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\n${t('score')}: ${gameState.score}\n\n#${config.appName} #Music\n\n${shareUrl}`;
|
||||||
|
|
||||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||||
|
|
||||||
@@ -210,8 +463,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
title: `Hördle #${dailyPuzzle.puzzleNumber}`,
|
||||||
text: text,
|
text: text,
|
||||||
});
|
});
|
||||||
setShareText('✓ Shared!');
|
setShareText(t('shared'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name !== 'AbortError') {
|
if ((err as Error).name !== 'AbortError') {
|
||||||
@@ -222,12 +475,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setShareText('✓ Copied!');
|
setShareText(t('copied'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Clipboard failed:', err);
|
console.error('Clipboard failed:', err);
|
||||||
setShareText('✗ Failed');
|
setShareText(t('shareFailed'));
|
||||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
setTimeout(() => setShareText(`🔗 ${t('share')}`), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -238,43 +491,54 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||||
setHasRated(true);
|
setHasRated(true);
|
||||||
|
|
||||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
const ratedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_rated_puzzles`) || '[]');
|
||||||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||||
ratedPuzzles.push(dailyPuzzle.id);
|
ratedPuzzles.push(dailyPuzzle.id);
|
||||||
localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles));
|
localStorage.setItem(`${config.appName.toLowerCase()}_rated_puzzles`, JSON.stringify(ratedPuzzles));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to submit rating', error);
|
console.error('Failed to submit rating', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<header className="header">
|
<header className="header">
|
||||||
<h1 className="title">Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
<h1 id="tour-title" className="title">{config.appName} #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}</h1>
|
||||||
<div style={{ fontSize: '0.9rem', color: '#666', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
<div style={{ fontSize: '0.9rem', color: 'var(--muted-foreground)', marginTop: '0.5rem', marginBottom: '1rem' }}>
|
||||||
Next puzzle in: {timeUntilNext}
|
{t('nextPuzzle')}: {timeUntilNext}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="game-board">
|
<main className="game-board">
|
||||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||||
<div className="status-bar">
|
<div id="tour-status" className="status-bar">
|
||||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
<span>{t('attempt')} {currentAttempt} / {maxAttempts}</span>
|
||||||
<span>{unlockedSeconds}s unlocked</span>
|
<span>{unlockedSeconds}s {t('unlocked')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
<div id="tour-score">
|
||||||
|
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<AudioPlayer
|
<div id="tour-player">
|
||||||
ref={audioPlayerRef}
|
<AudioPlayer
|
||||||
src={dailyPuzzle.audioUrl}
|
ref={audioPlayerRef}
|
||||||
unlockedSeconds={unlockedSeconds}
|
src={dailyPuzzle.audioUrl}
|
||||||
startTime={dailyPuzzle.startTime}
|
unlockedSeconds={unlockedSeconds}
|
||||||
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
startTime={dailyPuzzle.startTime}
|
||||||
onReplay={addReplay}
|
autoPlay={lastAction === 'SKIP' || (lastAction === 'GUESS' && !hasWon && !hasLost)}
|
||||||
onHasPlayedChange={setHasPlayedAudio}
|
onReplay={addReplay}
|
||||||
/>
|
onHasPlayedChange={setHasPlayedAudio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="guess-list">
|
<div className="guess-list">
|
||||||
@@ -284,7 +548,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
<div key={i} className="guess-item">
|
<div key={i} className="guess-item">
|
||||||
<span className="guess-number">#{i + 1}</span>
|
<span className="guess-number">#{i + 1}</span>
|
||||||
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
<span className={`guess-text ${guess === 'SKIPPED' ? 'skipped' : ''} ${isCorrect ? 'correct' : ''}`}>
|
||||||
{isCorrect ? 'Correct!' : guess}
|
{isCorrect ? t('correct') : guess}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -293,15 +557,18 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
{!hasWon && !hasLost && (
|
{!hasWon && !hasLost && (
|
||||||
<>
|
<>
|
||||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
<div id="tour-input">
|
||||||
|
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||||
|
</div>
|
||||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||||
<button
|
<button
|
||||||
|
id="tour-controls"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="skip-button"
|
className="skip-button"
|
||||||
>
|
>
|
||||||
{gameState.guesses.length === 0 && !hasPlayedAudio
|
{gameState.guesses.length === 0 && !hasPlayedAudio
|
||||||
? 'Start'
|
? t('start')
|
||||||
: `Skip (+${unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds}s)`
|
: t('skipWithBonus', { seconds: unlockSteps[Math.min(gameState.guesses.length + 1, unlockSteps.length - 1)] - unlockedSeconds })
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -313,7 +580,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
boxShadow: '0 4px 15px rgba(245, 87, 108, 0.4)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Solve (Give Up)
|
{t('solveGiveUp')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -322,15 +589,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
{(hasWon || hasLost) && (
|
{(hasWon || hasLost) && (
|
||||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||||
{hasWon ? 'You won!' : 'Game Over'}
|
{hasWon ? t('won') : t('lost')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? 'var(--success)' : 'var(--danger)' }}>
|
||||||
Score: {gameState.score}
|
{t('score')}: {gameState.score}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: 'var(--muted-foreground)' }}>
|
||||||
<summary>Score Breakdown</summary>
|
<summary>{t('scoreBreakdown')}</summary>
|
||||||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
{gameState.scoreBreakdown.map((item, i) => (
|
{gameState.scoreBreakdown.map((item, i) => (
|
||||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||||
@@ -343,33 +610,142 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<p>{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}</p>
|
<p>{hasWon ? t('comeBackTomorrow') : t('theSongWas')}</p>
|
||||||
|
|
||||||
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||||
<img
|
<img
|
||||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||||
alt="Album Cover"
|
alt={t('albumCover')}
|
||||||
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
|
||||||
/>
|
/>
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||||
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
{dailyPuzzle.releaseYear && gameState.yearGuessed && (
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
<p style={{ fontSize: '0.875rem', color: 'var(--muted-foreground)', margin: '0 0 1rem 0' }}>{t('released')}: {dailyPuzzle.releaseYear}</p>
|
||||||
)}
|
)}
|
||||||
<audio controls style={{ width: '100%' }}>
|
<audio controls style={{ width: '100%' }}>
|
||||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||||
Your browser does not support the audio element.
|
{t('yourBrowserDoesNotSupport')}
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div style={{ marginBottom: '1.25rem' }}>
|
||||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Comment Form */}
|
||||||
|
{!commentSent && (
|
||||||
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setCommentCollapsed(!commentCollapsed)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: commentCollapsed ? 0 : '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', margin: 0 }}>
|
||||||
|
{t('sendComment')}
|
||||||
|
</h3>
|
||||||
|
<span>{commentCollapsed ? '▼' : '▲'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!commentCollapsed && (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
||||||
|
{t('commentHelp')}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
placeholder={t('commentPlaceholder')}
|
||||||
|
maxLength={300}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '100px',
|
||||||
|
padding: '0.75rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
resize: 'vertical',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
display: 'block',
|
||||||
|
boxSizing: 'border-box' // Ensure padding and border are included in width
|
||||||
|
}}
|
||||||
|
disabled={commentSending}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
|
||||||
|
{commentText.length}/300
|
||||||
|
</span>
|
||||||
|
{commentError && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
|
||||||
|
{commentError}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '0.75rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', fontSize: '0.85rem', color: 'var(--foreground)', cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={commentAIConsent}
|
||||||
|
onChange={(e) => setCommentAIConsent(e.target.checked)}
|
||||||
|
disabled={commentSending || commentSent}
|
||||||
|
style={{ marginTop: '0.2rem', cursor: (commentSending || commentSent) ? 'not-allowed' : 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span>{t('commentAIConsent')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCommentSubmit}
|
||||||
|
disabled={!commentText.trim() || commentSending || commentSent || !commentAIConsent}
|
||||||
|
className="btn-primary"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
opacity: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 0.5 : 1,
|
||||||
|
cursor: (!commentText.trim() || commentSending || commentSent || !commentAIConsent) ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{commentSending ? t('sending') : t('sendComment')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{commentSent && (
|
||||||
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
|
||||||
|
{rewrittenMessage ? (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: '0.5rem' }}>
|
||||||
|
{t('commentSent')}
|
||||||
|
</p>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
|
||||||
|
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
|
||||||
|
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: 0 }}>
|
||||||
|
{t('commentThankYou')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{statistics && <Statistics statistics={statistics} />}
|
{statistics && <Statistics statistics={statistics} />}
|
||||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
|
||||||
{shareText}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
@@ -381,6 +757,13 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
onSkip={handleYearSkip}
|
onSkip={handleYearSkip}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showExtraPuzzlesPopover && extraPuzzle && (
|
||||||
|
<ExtraPuzzlesPopover
|
||||||
|
puzzle={extraPuzzle}
|
||||||
|
onClose={() => setShowExtraPuzzlesPopover(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -405,20 +788,22 @@ function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
margin: '0.5rem 0',
|
margin: '0.5rem 0',
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
background: '#f3f4f6',
|
background: 'var(--muted)',
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
cursor: 'help'
|
cursor: 'help'
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: '#666' }}>{expression} = </span>
|
<span style={{ color: 'var(--muted-foreground)' }}>{expression} = </span>
|
||||||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [options, setOptions] = useState<number[]>([]);
|
const [options, setOptions] = useState<number[]>([]);
|
||||||
|
const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -447,6 +832,24 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||||
}, [correctYear]);
|
}, [correctYear]);
|
||||||
|
|
||||||
|
const handleGuess = (year: number) => {
|
||||||
|
const correct = year === correctYear;
|
||||||
|
setFeedback({ show: true, correct, guessedYear: year });
|
||||||
|
|
||||||
|
// Close modal after showing feedback
|
||||||
|
setTimeout(() => {
|
||||||
|
onGuess(year);
|
||||||
|
}, 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
setFeedback({ show: true, correct: false });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onSkip();
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -470,67 +873,102 @@ function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number,
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||||
}}>
|
}}>
|
||||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
{!feedback.show ? (
|
||||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
<>
|
||||||
|
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: 'var(--primary)' }}>{t('bonusRound')}</h3>
|
||||||
|
<p style={{ marginBottom: '1.5rem', color: 'var(--secondary)' }}>{t('guessReleaseYear')} <strong style={{ color: 'var(--success)' }}>+10 {t('points')}</strong>!</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||||
|
gap: '0.75rem',
|
||||||
|
marginBottom: '1.5rem'
|
||||||
|
}}>
|
||||||
|
{options.map(year => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
onClick={() => handleGuess(year)}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: 'var(--muted)',
|
||||||
|
border: '2px solid var(--border)',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'var(--secondary)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseOver={e => e.currentTarget.style.borderColor = 'var(--success)'}
|
||||||
|
onMouseOut={e => e.currentTarget.style.borderColor = 'var(--border)'}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
|
||||||
gap: '0.75rem',
|
|
||||||
marginBottom: '1.5rem'
|
|
||||||
}}>
|
|
||||||
{options.map(year => (
|
|
||||||
<button
|
<button
|
||||||
key={year}
|
onClick={handleSkip}
|
||||||
onClick={() => onGuess(year)}
|
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem',
|
background: 'none',
|
||||||
background: '#f3f4f6',
|
border: 'none',
|
||||||
border: '2px solid #e5e7eb',
|
color: 'var(--muted-foreground)',
|
||||||
borderRadius: '0.5rem',
|
textDecoration: 'underline',
|
||||||
fontSize: '1.1rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: '#374151',
|
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
fontSize: '0.9rem'
|
||||||
}}
|
}}
|
||||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
|
||||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
|
||||||
>
|
>
|
||||||
{year}
|
{t('skipBonus')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<div style={{ padding: '2rem 0' }}>
|
||||||
<button
|
{feedback.guessedYear ? (
|
||||||
onClick={onSkip}
|
feedback.correct ? (
|
||||||
style={{
|
<>
|
||||||
background: 'none',
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>🎉</div>
|
||||||
border: 'none',
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--success)', marginBottom: '0.5rem' }}>{t('correct')}</h3>
|
||||||
color: '#6b7280',
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||||
textDecoration: 'underline',
|
<p style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--success)', marginTop: '1rem' }}>+10 {t('points')}!</p>
|
||||||
cursor: 'pointer',
|
</>
|
||||||
fontSize: '0.9rem'
|
) : (
|
||||||
}}
|
<>
|
||||||
>
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>😕</div>
|
||||||
Skip Bonus
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--danger)', marginBottom: '0.5rem' }}>{t('notQuite')}</h3>
|
||||||
</button>
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('youGuessed')} {feedback.guessedYear}</p>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)', marginTop: '0.5rem' }}>{t('actuallyReleasedIn')} <strong>{correctYear}</strong></p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: '4rem', marginBottom: '1rem' }}>⏭️</div>
|
||||||
|
<h3 style={{ fontSize: '2rem', fontWeight: 'bold', color: 'var(--muted-foreground)', marginBottom: '0.5rem' }}>{t('skipped')}</h3>
|
||||||
|
<p style={{ fontSize: '1.2rem', color: 'var(--secondary)' }}>{t('released')} {correctYear}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [hover, setHover] = useState(0);
|
const [hover, setHover] = useState(0);
|
||||||
const [rating, setRating] = useState(0);
|
const [rating, setRating] = useState(0);
|
||||||
|
|
||||||
if (hasRated) {
|
if (hasRated) {
|
||||||
return <div style={{ color: '#666', fontStyle: 'italic' }}>Thanks for rating!</div>;
|
return <div style={{ color: 'var(--muted-foreground)', fontStyle: 'italic' }}>{t('thanksForRating')}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="star-rating" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
<div
|
||||||
<span style={{ fontSize: '0.875rem', color: '#666', fontWeight: '500' }}>Rate this puzzle:</span>
|
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' }}>
|
<div style={{ display: 'flex', gap: '0.25rem', justifyContent: 'center' }}>
|
||||||
{[...Array(5)].map((_, index) => {
|
{[...Array(5)].map((_, index) => {
|
||||||
const ratingValue = index + 1;
|
const ratingValue = index + 1;
|
||||||
@@ -543,7 +981,7 @@ function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, ha
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '2rem',
|
fontSize: '2rem',
|
||||||
color: ratingValue <= (hover || rating) ? '#ffc107' : '#9ca3af',
|
color: ratingValue <= (hover || rating) ? 'var(--warning)' : 'var(--muted-foreground)',
|
||||||
transition: 'color 0.2s',
|
transition: 'color 0.2s',
|
||||||
padding: '0 0.25rem'
|
padding: '0 0.25rem'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
interface Song {
|
interface Song {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -14,15 +15,32 @@ interface GuessInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
||||||
|
const t = useTranslations('Game');
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [songs, setSongs] = useState<Song[]>([]);
|
const [songs, setSongs] = useState<Song[]>([]);
|
||||||
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
const [filteredSongs, setFilteredSongs] = useState<Song[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/songs')
|
fetch('/api/public-songs')
|
||||||
.then(res => res.json())
|
.then(res => {
|
||||||
.then(data => setSongs(data));
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -53,7 +71,7 @@ export default function GuessInput({ onGuess, disabled }: GuessInputProps) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={disabled ? "Game Over" : "Know it? Search for the artist / title"}
|
placeholder={disabled ? t('gameOverPlaceholder') : t('knowItSearch')}
|
||||||
className="guess-input"
|
className="guess-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
175
components/HelpTooltip.tsx
Normal file
175
components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
interface HelpTooltipProps {
|
||||||
|
shortText: string; // Text für Hover
|
||||||
|
longText: string; // Text für Click/Modal
|
||||||
|
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HelpTooltip({ shortText, longText, position = 'top' }: HelpTooltipProps) {
|
||||||
|
const t = useTranslations('CuratorHelp');
|
||||||
|
const [showHover, setShowHover] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
tooltipRef.current &&
|
||||||
|
!tooltipRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setShowModal(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showModal) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showModal]);
|
||||||
|
|
||||||
|
const positionStyles = {
|
||||||
|
top: { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: '0.5rem' },
|
||||||
|
bottom: { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: '0.5rem' },
|
||||||
|
left: { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: '0.5rem' },
|
||||||
|
right: { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: '0.5rem' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(!showModal)}
|
||||||
|
onMouseEnter={() => setShowHover(true)}
|
||||||
|
onMouseLeave={() => setShowHover(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#6b7280',
|
||||||
|
fontSize: '1rem',
|
||||||
|
padding: '0.25rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '1.5rem',
|
||||||
|
height: '1.5rem',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
aria-label="Help"
|
||||||
|
>
|
||||||
|
ℹ
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hover Tooltip */}
|
||||||
|
{showHover && !showModal && (
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
...positionStyles[position],
|
||||||
|
background: '#1f2937',
|
||||||
|
color: 'white',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
zIndex: 1000,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
maxWidth: '250px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shortText}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
...(position === 'top' && { top: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '6px solid #1f2937' }),
|
||||||
|
...(position === 'bottom' && { bottom: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderBottom: '6px solid #1f2937' }),
|
||||||
|
...(position === 'left' && { left: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderLeft: '6px solid #1f2937' }),
|
||||||
|
...(position === 'right' && { right: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderRight: '6px solid #1f2937' }),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal für detaillierte Informationen */}
|
||||||
|
{showModal && (
|
||||||
|
<>
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
zIndex: 9998,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
/>
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
background: 'white',
|
||||||
|
padding: '1.5rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 'bold' }}>{t('modalTitle')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#6b7280',
|
||||||
|
padding: '0',
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '0.9rem', lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{longText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function InstallPrompt() {
|
export default function InstallPrompt() {
|
||||||
|
const t = useTranslations('InstallPrompt');
|
||||||
const [isIOS, setIsIOS] = useState(false);
|
const [isIOS, setIsIOS] = useState(false);
|
||||||
const [isStandalone, setIsStandalone] = useState(false);
|
const [isStandalone, setIsStandalone] = useState(false);
|
||||||
const [showPrompt, setShowPrompt] = useState(false);
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
@@ -80,9 +82,9 @@ export default function InstallPrompt() {
|
|||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>Install Hördle App</h3>
|
<h3 style={{ fontWeight: 'bold', fontSize: '1rem', marginBottom: '0.25rem' }}>{t('installApp')}</h3>
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
<p style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||||
Install the app for a better experience and quick access!
|
{t('installDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -102,7 +104,7 @@ export default function InstallPrompt() {
|
|||||||
|
|
||||||
{isIOS ? (
|
{isIOS ? (
|
||||||
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
<div style={{ fontSize: '0.875rem', background: '#f3f4f6', padding: '0.75rem', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
Tap <span style={{ fontSize: '1.2rem' }}>share</span> then "Add to Home Screen" <span style={{ fontSize: '1.2rem' }}>+</span>
|
{t('iosInstructions')} <span style={{ fontSize: '1.2rem' }}>{t('iosShare')}</span> {t('iosThen')} <span style={{ fontSize: '1.2rem' }}>+</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -118,7 +120,7 @@ export default function InstallPrompt() {
|
|||||||
marginTop: '0.5rem'
|
marginTop: '0.5rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Install App
|
{t('installButton')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<style jsx>{`
|
<style jsx>{`
|
||||||
|
|||||||
59
components/LanguageSwitcher.tsx
Normal file
59
components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from '@/lib/navigation';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
|
export default function LanguageSwitcher() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const switchLocale = (newLocale: 'de' | 'en') => {
|
||||||
|
router.replace(pathname, { locale: newLocale });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
padding: '0.25rem',
|
||||||
|
gap: '0.25rem'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => switchLocale('de')}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: locale === 'de' ? 'white' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: locale === 'de' ? '600' : '400',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: locale === 'de' ? '#111827' : '#6b7280',
|
||||||
|
boxShadow: locale === 'de' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchLocale('en')}
|
||||||
|
style={{
|
||||||
|
padding: '0.375rem 0.75rem',
|
||||||
|
background: locale === 'en' ? 'white' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: locale === 'en' ? '600' : '400',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: locale === 'en' ? '#111827' : '#6b7280',
|
||||||
|
boxShadow: locale === 'en' ? '0 1px 2px rgba(0,0,0,0.05)' : 'none',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,33 +2,38 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import Link from 'next/link';
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { getLocalizedValue } from '@/lib/i18n';
|
||||||
|
|
||||||
interface NewsItem {
|
interface NewsItem {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: any;
|
||||||
content: string;
|
content: any;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
special: {
|
special: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: any;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewsSection() {
|
interface NewsSectionProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewsSection({ locale }: NewsSectionProps) {
|
||||||
const [news, setNews] = useState<NewsItem[]>([]);
|
const [news, setNews] = useState<NewsItem[]>([]);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNews();
|
fetchNews();
|
||||||
}, []);
|
}, [locale]);
|
||||||
|
|
||||||
const fetchNews = async () => {
|
const fetchNews = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/news?limit=3');
|
const res = await fetch(`/api/news?limit=3&locale=${locale}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setNews(data);
|
setNews(data);
|
||||||
@@ -115,7 +120,7 @@ export default function NewsSection() {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#111827'
|
color: '#111827'
|
||||||
}}>
|
}}>
|
||||||
{item.title}
|
{getLocalizedValue(item.title, locale)}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,14 +150,14 @@ export default function NewsSection() {
|
|||||||
<>
|
<>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<Link
|
<Link
|
||||||
href={`/special/${item.special.name}`}
|
href={`/special/${getLocalizedValue(item.special.name, locale)}`}
|
||||||
style={{
|
style={{
|
||||||
color: '#be185d',
|
color: '#be185d',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
★ {item.special.name}
|
★ {getLocalizedValue(item.special.name, locale)}
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -187,7 +192,7 @@ export default function NewsSection() {
|
|||||||
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
li: ({ children }) => <li style={{ margin: '0.25rem 0' }}>{children}</li>
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.content}
|
{getLocalizedValue(item.content, locale)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
112
components/OnboardingTour.tsx
Normal file
112
components/OnboardingTour.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { driver } from 'driver.js';
|
||||||
|
import 'driver.js/dist/driver.css';
|
||||||
|
|
||||||
|
export default function OnboardingTour() {
|
||||||
|
const t = useTranslations('OnboardingTour');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasCompletedOnboarding = localStorage.getItem('hoerdle_onboarding_completed');
|
||||||
|
|
||||||
|
if (hasCompletedOnboarding) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverObj = driver({
|
||||||
|
showProgress: true,
|
||||||
|
animate: true,
|
||||||
|
allowClose: true,
|
||||||
|
doneBtnText: t('done'),
|
||||||
|
nextBtnText: t('next'),
|
||||||
|
prevBtnText: t('previous'),
|
||||||
|
onDestroyed: () => {
|
||||||
|
localStorage.setItem('hoerdle_onboarding_completed', 'true');
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
element: '#tour-genres',
|
||||||
|
popover: {
|
||||||
|
title: t('genresSpecials'),
|
||||||
|
description: t('genresSpecialsDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-news',
|
||||||
|
popover: {
|
||||||
|
title: t('news'),
|
||||||
|
description: t('newsDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-title',
|
||||||
|
popover: {
|
||||||
|
title: t('hoerdle'),
|
||||||
|
description: t('hoerdleDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-status',
|
||||||
|
popover: {
|
||||||
|
title: t('attempts'),
|
||||||
|
description: t('attemptsDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-score',
|
||||||
|
popover: {
|
||||||
|
title: t('score'),
|
||||||
|
description: t('scoreDescription'),
|
||||||
|
side: 'bottom',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-player',
|
||||||
|
popover: {
|
||||||
|
title: t('player'),
|
||||||
|
description: t('playerDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-input',
|
||||||
|
popover: {
|
||||||
|
title: t('input'),
|
||||||
|
description: t('inputDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '#tour-controls',
|
||||||
|
popover: {
|
||||||
|
title: t('controls'),
|
||||||
|
description: t('controlsDescription'),
|
||||||
|
side: 'top',
|
||||||
|
align: 'start'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
driverObj.drive();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
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';
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Statistics as StatsType } from '../lib/gameState';
|
import { Statistics as StatsType } from '../lib/gameState';
|
||||||
|
|
||||||
interface StatisticsProps {
|
interface StatisticsProps {
|
||||||
@@ -18,6 +19,7 @@ const BADGES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Statistics({ statistics }: StatisticsProps) {
|
export default function Statistics({ statistics }: StatisticsProps) {
|
||||||
|
const t = useTranslations('Statistics');
|
||||||
const total =
|
const total =
|
||||||
statistics.solvedIn1 +
|
statistics.solvedIn1 +
|
||||||
statistics.solvedIn2 +
|
statistics.solvedIn2 +
|
||||||
@@ -36,19 +38,19 @@ export default function Statistics({ statistics }: StatisticsProps) {
|
|||||||
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
{ attempts: 5, count: statistics.solvedIn5, badge: BADGES[5] },
|
||||||
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
{ attempts: 6, count: statistics.solvedIn6, badge: BADGES[6] },
|
||||||
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
|
{ attempts: 7, count: statistics.solvedIn7, badge: BADGES[7] },
|
||||||
{ attempts: 'Failed', count: statistics.failed, badge: BADGES.failed },
|
{ attempts: t('failed'), count: statistics.failed, badge: BADGES.failed },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="statistics-container">
|
<div className="statistics-container">
|
||||||
<h3 className="statistics-title">Your Statistics</h3>
|
<h3 className="statistics-title">{t('yourStatistics')}</h3>
|
||||||
<p className="statistics-total">Total puzzles: {total}</p>
|
<p className="statistics-total">{t('totalPuzzles')}: {total}</p>
|
||||||
<div className="statistics-grid">
|
<div className="statistics-grid">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, index) => (
|
||||||
<div key={index} className="stat-item">
|
<div key={index} className="stat-item">
|
||||||
<div className="stat-badge">{stat.badge}</div>
|
<div className="stat-badge">{stat.badge}</div>
|
||||||
<div className="stat-label">
|
<div className="stat-label">
|
||||||
{typeof stat.attempts === 'number' ? `${stat.attempts} try` : stat.attempts}
|
{typeof stat.attempts === 'number' ? `${stat.attempts} ${t('try')}` : stat.attempts}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-count">{stat.count}</div>
|
<div className="stat-count">{stat.count}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ interface WaveformEditorProps {
|
|||||||
|
|
||||||
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
|
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const timelineRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
|
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
|
||||||
const [audioDuration, setAudioDuration] = useState(0);
|
const [audioDuration, setAudioDuration] = useState(0);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [playingSegment, setPlayingSegment] = useState<number | null>(null);
|
const [playingSegment, setPlayingSegment] = useState<number | null>(null);
|
||||||
|
const [isPlayingFullTitle, setIsPlayingFullTitle] = useState(false);
|
||||||
|
const [pausedPosition, setPausedPosition] = useState<number | null>(null); // Position when paused
|
||||||
|
const [pausedType, setPausedType] = useState<'selection' | 'title' | null>(null); // Type of playback that was paused
|
||||||
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
|
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
|
||||||
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
|
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
|
||||||
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
|
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
|
||||||
@@ -55,6 +59,80 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
|
|||||||
};
|
};
|
||||||
}, [audioUrl]);
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
// Draw timeline
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioDuration || !timelineRef.current) return;
|
||||||
|
|
||||||
|
const timeline = timelineRef.current;
|
||||||
|
const ctx = timeline.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const width = timeline.width;
|
||||||
|
const height = timeline.height;
|
||||||
|
|
||||||
|
// Calculate visible range based on zoom and offset (same as waveform)
|
||||||
|
const visibleDuration = audioDuration / zoom;
|
||||||
|
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
|
||||||
|
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
|
||||||
|
|
||||||
|
// Clear timeline
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw border
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Calculate appropriate time interval based on visible duration
|
||||||
|
let timeInterval = 1; // Start with 1 second
|
||||||
|
if (visibleDuration > 60) timeInterval = 10;
|
||||||
|
else if (visibleDuration > 30) timeInterval = 5;
|
||||||
|
else if (visibleDuration > 10) timeInterval = 2;
|
||||||
|
else if (visibleDuration > 5) timeInterval = 1;
|
||||||
|
else if (visibleDuration > 1) timeInterval = 0.5;
|
||||||
|
else timeInterval = 0.1;
|
||||||
|
|
||||||
|
// Draw time markers
|
||||||
|
ctx.strokeStyle = '#9ca3af';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
const startTimeMarker = Math.floor(visibleStart / timeInterval) * timeInterval;
|
||||||
|
for (let time = startTimeMarker; time <= visibleEnd; time += timeInterval) {
|
||||||
|
const timePx = ((time - visibleStart) / visibleDuration) * width;
|
||||||
|
|
||||||
|
if (timePx >= 0 && timePx <= width) {
|
||||||
|
// Draw tick mark
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(timePx, 0);
|
||||||
|
ctx.lineTo(timePx, height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw time label
|
||||||
|
const timeLabel = time.toFixed(timeInterval < 1 ? 1 : 0);
|
||||||
|
ctx.fillText(`${timeLabel}s`, timePx, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw current playback position if playing
|
||||||
|
if (playbackPosition !== null) {
|
||||||
|
const playbackPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
|
||||||
|
if (playbackPx >= 0 && playbackPx <= width) {
|
||||||
|
ctx.strokeStyle = '#10b981';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(playbackPx, 0);
|
||||||
|
ctx.lineTo(playbackPx, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [audioDuration, zoom, viewOffset, playbackPosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioBuffer || !canvasRef.current) return;
|
if (!audioBuffer || !canvasRef.current) return;
|
||||||
|
|
||||||
@@ -133,6 +211,24 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
|
|||||||
|
|
||||||
cumulativeTime = step;
|
cumulativeTime = step;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Draw end marker for the last segment (at startTime + duration)
|
||||||
|
const endTime = startTime + duration;
|
||||||
|
const endPx = ((endTime - visibleStart) / visibleDuration) * width;
|
||||||
|
if (endPx >= 0 && endPx <= width) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(endPx, 0);
|
||||||
|
ctx.lineTo(endPx, height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw "End" label
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = '#ef4444';
|
||||||
|
ctx.font = 'bold 12px sans-serif';
|
||||||
|
ctx.fillText('End', endPx + 3, 15);
|
||||||
|
ctx.setLineDash([5, 5]);
|
||||||
|
}
|
||||||
|
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
// Draw hover preview (semi-transparent)
|
// Draw hover preview (semi-transparent)
|
||||||
@@ -215,11 +311,21 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
|
|||||||
setHoverPreviewTime(null);
|
setHoverPreviewTime(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopPlayback = () => {
|
const stopPlayback = (savePosition = false) => {
|
||||||
|
if (savePosition && playbackPosition !== null) {
|
||||||
|
// Save current position for resume
|
||||||
|
setPausedPosition(playbackPosition);
|
||||||
|
// Keep playbackPosition visible (don't set to null) so cursor stays visible
|
||||||
|
} else {
|
||||||
|
// Clear paused position if stopping completely
|
||||||
|
setPausedPosition(null);
|
||||||
|
setPausedType(null);
|
||||||
|
setPlaybackPosition(null);
|
||||||
|
}
|
||||||
sourceRef.current?.stop();
|
sourceRef.current?.stop();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setPlayingSegment(null);
|
setPlayingSegment(null);
|
||||||
setPlaybackPosition(null);
|
setIsPlayingFullTitle(false);
|
||||||
if (animationFrameRef.current) {
|
if (animationFrameRef.current) {
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
animationFrameRef.current = null;
|
animationFrameRef.current = null;
|
||||||
@@ -287,30 +393,119 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
|
|||||||
const handlePlayFull = () => {
|
const handlePlayFull = () => {
|
||||||
if (!audioBuffer || !audioContextRef.current) return;
|
if (!audioBuffer || !audioContextRef.current) return;
|
||||||
|
|
||||||
if (isPlaying) {
|
// If full selection playback is already playing, pause it
|
||||||
stopPlayback();
|
if (isPlaying && playingSegment === null && !isPlayingFullTitle) {
|
||||||
} else {
|
stopPlayback(true); // Save position
|
||||||
const source = audioContextRef.current.createBufferSource();
|
setPausedType('selection');
|
||||||
source.buffer = audioBuffer;
|
return;
|
||||||
source.connect(audioContextRef.current.destination);
|
|
||||||
|
|
||||||
playbackStartTimeRef.current = audioContextRef.current.currentTime;
|
|
||||||
playbackOffsetRef.current = startTime;
|
|
||||||
|
|
||||||
source.start(0, startTime, duration);
|
|
||||||
sourceRef.current = source;
|
|
||||||
setIsPlaying(true);
|
|
||||||
setPlaybackPosition(startTime);
|
|
||||||
|
|
||||||
source.onended = () => {
|
|
||||||
setIsPlaying(false);
|
|
||||||
setPlaybackPosition(null);
|
|
||||||
if (animationFrameRef.current) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
animationFrameRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop any current playback (segment, full selection, or full title)
|
||||||
|
stopPlayback();
|
||||||
|
|
||||||
|
// Determine start position (resume from pause or start from beginning)
|
||||||
|
const resumePosition = pausedType === 'selection' && pausedPosition !== null
|
||||||
|
? pausedPosition
|
||||||
|
: startTime;
|
||||||
|
const remainingDuration = resumePosition >= startTime + duration
|
||||||
|
? 0
|
||||||
|
: (startTime + duration) - resumePosition;
|
||||||
|
|
||||||
|
if (remainingDuration <= 0) {
|
||||||
|
// Already finished, reset
|
||||||
|
setPausedPosition(null);
|
||||||
|
setPausedType(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start full selection playback
|
||||||
|
const source = audioContextRef.current.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContextRef.current.destination);
|
||||||
|
|
||||||
|
playbackStartTimeRef.current = audioContextRef.current.currentTime;
|
||||||
|
playbackOffsetRef.current = resumePosition;
|
||||||
|
|
||||||
|
source.start(0, resumePosition, remainingDuration);
|
||||||
|
sourceRef.current = source;
|
||||||
|
setIsPlaying(true);
|
||||||
|
setPlayingSegment(null);
|
||||||
|
setIsPlayingFullTitle(false);
|
||||||
|
setPausedPosition(null);
|
||||||
|
setPausedType(null);
|
||||||
|
setPlaybackPosition(resumePosition);
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setPlayingSegment(null);
|
||||||
|
setIsPlayingFullTitle(false);
|
||||||
|
setPlaybackPosition(null);
|
||||||
|
setPausedPosition(null);
|
||||||
|
setPausedType(null);
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayFullTitle = () => {
|
||||||
|
if (!audioBuffer || !audioContextRef.current) return;
|
||||||
|
|
||||||
|
// If full title playback is already playing, pause it
|
||||||
|
if (isPlaying && isPlayingFullTitle) {
|
||||||
|
stopPlayback(true); // Save position
|
||||||
|
setPausedType('title');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any current playback (segment, full selection, or full title)
|
||||||
|
stopPlayback();
|
||||||
|
|
||||||
|
// Determine start position (resume from pause or start from beginning)
|
||||||
|
const resumePosition = pausedType === 'title' && pausedPosition !== null
|
||||||
|
? pausedPosition
|
||||||
|
: 0;
|
||||||
|
const remainingDuration = resumePosition >= audioDuration
|
||||||
|
? 0
|
||||||
|
: audioDuration - resumePosition;
|
||||||
|
|
||||||
|
if (remainingDuration <= 0) {
|
||||||
|
// Already finished, reset
|
||||||
|
setPausedPosition(null);
|
||||||
|
setPausedType(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start full title playback (from resumePosition to audioDuration)
|
||||||
|
const source = audioContextRef.current.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContextRef.current.destination);
|
||||||
|
|
||||||
|
playbackStartTimeRef.current = audioContextRef.current.currentTime;
|
||||||
|
playbackOffsetRef.current = resumePosition;
|
||||||
|
|
||||||
|
source.start(0, resumePosition, remainingDuration);
|
||||||
|
sourceRef.current = source;
|
||||||
|
setIsPlaying(true);
|
||||||
|
setPlayingSegment(null);
|
||||||
|
setIsPlayingFullTitle(true);
|
||||||
|
setPausedPosition(null);
|
||||||
|
setPausedType(null);
|
||||||
|
setPlaybackPosition(resumePosition);
|
||||||
|
|
||||||
|
source.onended = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setPlayingSegment(null);
|
||||||
|
setIsPlayingFullTitle(false);
|
||||||
|
setPlaybackPosition(null);
|
||||||
|
setPausedPosition(null);
|
||||||
|
setPausedType(null);
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10));
|
const handleZoomIn = () => setZoom(prev => Math.min(prev * 1.5, 10));
|
||||||
@@ -371,21 +566,38 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<canvas
|
<div style={{ position: 'relative' }}>
|
||||||
ref={canvasRef}
|
<canvas
|
||||||
width={800}
|
ref={canvasRef}
|
||||||
height={150}
|
width={800}
|
||||||
onClick={handleCanvasClick}
|
height={150}
|
||||||
onMouseMove={handleCanvasMouseMove}
|
onClick={handleCanvasClick}
|
||||||
onMouseLeave={handleCanvasMouseLeave}
|
onMouseMove={handleCanvasMouseMove}
|
||||||
style={{
|
onMouseLeave={handleCanvasMouseLeave}
|
||||||
width: '100%',
|
style={{
|
||||||
height: 'auto',
|
width: '100%',
|
||||||
cursor: 'pointer',
|
height: 'auto',
|
||||||
border: '1px solid #e5e7eb',
|
cursor: 'pointer',
|
||||||
borderRadius: '0.5rem'
|
border: '1px solid #e5e7eb',
|
||||||
}}
|
borderRadius: '0.5rem 0.5rem 0 0',
|
||||||
/>
|
display: 'block'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
ref={timelineRef}
|
||||||
|
width={800}
|
||||||
|
height={30}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '30px',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderTop: 'none',
|
||||||
|
borderRadius: '0 0 0.5rem 0.5rem',
|
||||||
|
display: 'block',
|
||||||
|
background: '#ffffff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Playback Controls */}
|
{/* Playback Controls */}
|
||||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
@@ -401,7 +613,29 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
|
|||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPlaying && playingSegment === null ? '⏸ Pause' : '▶ Play Full Selection'}
|
{isPlaying && playingSegment === null && !isPlayingFullTitle
|
||||||
|
? '⏸ Pause'
|
||||||
|
: (pausedType === 'selection' && pausedPosition !== null
|
||||||
|
? '▶ Resume'
|
||||||
|
: '▶ Play Full Selection')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handlePlayFullTitle}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPlaying && isPlayingFullTitle
|
||||||
|
? '⏸ Pause'
|
||||||
|
: (pausedType === 'title' && pausedPosition !== null
|
||||||
|
? '▶ Resume'
|
||||||
|
: '▶ Play Full Title')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
<div style={{ fontSize: '0.875rem', color: '#666' }}>
|
||||||
|
|||||||
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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_APP_NAME: ${NEXT_PUBLIC_APP_NAME}
|
||||||
|
NEXT_PUBLIC_APP_DESCRIPTION: ${NEXT_PUBLIC_APP_DESCRIPTION}
|
||||||
|
NEXT_PUBLIC_DOMAIN: ${NEXT_PUBLIC_DOMAIN}
|
||||||
|
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC: ${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC}
|
||||||
|
NEXT_PUBLIC_THEME_COLOR: ${NEXT_PUBLIC_THEME_COLOR}
|
||||||
|
NEXT_PUBLIC_BACKGROUND_COLOR: ${NEXT_PUBLIC_BACKGROUND_COLOR}
|
||||||
|
NEXT_PUBLIC_CREDITS_ENABLED: ${NEXT_PUBLIC_CREDITS_ENABLED}
|
||||||
|
NEXT_PUBLIC_CREDITS_TEXT: ${NEXT_PUBLIC_CREDITS_TEXT}
|
||||||
|
NEXT_PUBLIC_CREDITS_LINK_TEXT: ${NEXT_PUBLIC_CREDITS_LINK_TEXT}
|
||||||
|
NEXT_PUBLIC_CREDITS_LINK_URL: ${NEXT_PUBLIC_CREDITS_LINK_URL}
|
||||||
user: root
|
user: root
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
@@ -24,6 +35,11 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
# Run migrations and start server (auto-baseline on first run if needed)
|
networks:
|
||||||
command: >
|
- default
|
||||||
sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node server.js"
|
# docker-entrypoint.sh handles migrations and server startup (with baseline fallback)
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: hoerdle_default
|
||||||
|
external: true
|
||||||
|
|||||||
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`
|
||||||
|
|
||||||
@@ -82,3 +82,35 @@ docker ps
|
|||||||
```
|
```
|
||||||
|
|
||||||
Look for the "healthy" status in the STATUS column.
|
Look for the "healthy" status in the STATUS column.
|
||||||
|
|
||||||
|
## Caddy Reverse Proxy (Optional - Production)
|
||||||
|
|
||||||
|
For production deployments with automatic SSL/TLS certificates, Caddy can be used as a reverse proxy. Caddy provides:
|
||||||
|
|
||||||
|
- Automatic Let's Encrypt certificates (including wildcard certificates)
|
||||||
|
- HTTP to HTTPS redirect
|
||||||
|
- Optimized settings for audio streaming and file uploads
|
||||||
|
- Support for both `hoerdle.de` and `hördle.de` (Punycode: `xn--hrdle-jua.de`)
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. **Follow the setup guide**: See `CADDY_SETUP.md` for detailed instructions
|
||||||
|
2. **Configure environment variables**: Add GoDaddy API credentials to your `.env` file
|
||||||
|
3. **Start with Caddy**:
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.caddy.yml --profile production up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Caddy
|
||||||
|
|
||||||
|
If you don't want to use Caddy, you can deploy normally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will still be accessible on port 3010, but you'll need to configure SSL/TLS separately (e.g., with nginx).
|
||||||
|
|
||||||
|
### Caddy Troubleshooting
|
||||||
|
|
||||||
|
See `CADDY_SETUP.md` for detailed troubleshooting information.
|
||||||
106
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)
|
||||||
|
|
||||||
88
docs/TESTING.md
Normal file
88
docs/TESTING.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Integration Testing
|
||||||
|
|
||||||
|
Hördle uses [Playwright](https://playwright.dev/) for end-to-end (E2E) integration testing. These tests ensure that critical flows like gameplay, authentication, and admin management function correctly across different browsers.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Ensure you have the Playwright browsers installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Headless Mode (CI/CLI)
|
||||||
|
|
||||||
|
To run all tests in headless mode (Chromium, Firefox, WebKit):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI Mode (Interactive)
|
||||||
|
|
||||||
|
To run tests with a UI to inspect traces and watch execution:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:e2e:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Test File
|
||||||
|
|
||||||
|
To run a specific test file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test tests/gameplay.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specific Project (Browser)
|
||||||
|
|
||||||
|
To run tests only on a specific browser (e.g., Chromium):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project=chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The Playwright configuration is located in `playwright.config.ts`. It sets up the base URL (default: `http://localhost:3000`) and the web server command to start the app if it's not running.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
* **`ADMIN_PASSWORD`**: The tests assume the admin password is `'admin123'`.
|
||||||
|
* In `app/api/admin/login/route.ts`, the login logic has been enhanced to check if `ADMIN_PASSWORD` is a bcrypt hash (starts with `$2b$`) or plain text.
|
||||||
|
* For local testing, you can set `ADMIN_PASSWORD="admin123"` in your `.env` or `.env.local` (though the default fallback in the code also handles this).
|
||||||
|
* **Curator Credentials**: The mock Curator login page (`app/[locale]/curator/page.tsx`) mocks validation for testing.
|
||||||
|
* Username: `elpatron`
|
||||||
|
* Password: `example_password`
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
Tests are located in the `tests/` directory:
|
||||||
|
|
||||||
|
* **`auth.spec.ts`**: Verifies public access and admin login flows.
|
||||||
|
* **`admin.spec.ts`**: Checks the Admin Dashboard, including access protection and visibility of sections (Dashboard, Daily Puzzles, etc.).
|
||||||
|
* **`curator.spec.ts`**: Verifies the Curator login form and authentication flows (valid/invalid credentials).
|
||||||
|
* **`gameplay.spec.ts`**: Tests the core game loop: loading the game, playing audio, interaction with the prompt, and submitting a guess.
|
||||||
|
|
||||||
|
## Troubleshooting & Known Issues
|
||||||
|
|
||||||
|
### Next.js Development Overlay (`nextjs-portal`)
|
||||||
|
|
||||||
|
In development mode (`npm run dev`), Next.js injects an overlay (`<nextjs-portal>`) for hot reloading feedback. This overlay can sometimes intercept clicks intended for UI elements, causing tests to fail with "element is not clickable" or timeout errors.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
We inject a CSS style in the `beforeEach` hook of our tests to hide this overlay:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.addStyleTag({ content: 'nextjs-portal, #nextjs-dev-overlay, [data-nextjs-dev-overlay] { display: none !important; }' });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebKit (Safari) Stability
|
||||||
|
|
||||||
|
WebKit can be slower or more sensitive to timing in some environments. If tests fail on WebKit but pass on other browsers:
|
||||||
|
1. Try increasing the timeout in `playwright.config.ts`.
|
||||||
|
2. Use `await page.waitForTimeout(500)` or specific assertions like `await expect(page).toHaveURL(...)` to allow for transition times, as implemented in `tests/admin.spec.ts`.
|
||||||
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
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user